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
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "../memory_paths"
5
+
6
+ module Samagotchi
7
+ module MemoryBundle
8
+ # Where a bundle operation puts the memory files it takes away
9
+ # (uninstall, an upgrade's dropped file): moved, never deleted, into
10
+ # <system memories>/.bundles/.trash/<bundle>-<YYYYmmdd-HHMMSS>/, one dir
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.
13
+ #
14
+ # `chi bundle trash` lists and empties the trash.
15
+ class Trash
16
+ # A single trash entry (one directory in the trash).
17
+ Entry = Data.define(:name, :path, :time, :files, :bytes)
18
+
19
+ def self.root(bundles_dir: MemoryPaths.bundles_dir)
20
+ File.join(bundles_dir, ".trash")
21
+ end
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
+
58
+ # The dir, once a file was moved (nil before).
59
+ attr_reader :dir
60
+
61
+ def initialize(name, now: Time.now, bundles_dir: MemoryPaths.bundles_dir)
62
+ @name = name.to_s
63
+ @now = now
64
+ @bundles_dir = bundles_dir
65
+ @dir = nil
66
+ end
67
+
68
+ # Moves +path+ into the trash dir (created on the first call).
69
+ # @return [String] the file's new path
70
+ def move(path)
71
+ dest = File.join(ensure_dir, File.basename(path))
72
+ FileUtils.mv(path, dest)
73
+ dest
74
+ end
75
+
76
+ private
77
+
78
+ def ensure_dir
79
+ return @dir if @dir
80
+
81
+ base = File.join(self.class.root(bundles_dir: @bundles_dir), "#{@name}-#{@now.strftime("%Y%m%d-%H%M%S")}")
82
+ FileUtils.mkdir_p(File.dirname(base))
83
+ candidate = base
84
+ n = 1
85
+ loop do
86
+ Dir.mkdir(candidate)
87
+ break
88
+ rescue Errno::EEXIST
89
+ n += 1
90
+ candidate = "#{base}-#{n}"
91
+ end
92
+ @dir = candidate
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
134
+ end
135
+ end
136
+ end
@@ -5,13 +5,17 @@ require_relative "../memory_paths"
5
5
  require_relative "provenance"
6
6
  require_relative "index_updater"
7
7
  require_relative "merger"
8
+ require_relative "trash"
8
9
 
9
10
  module Samagotchi
10
11
  module MemoryBundle
11
12
  class Uninstaller
12
13
  class UninstallError < StandardError; end
13
14
 
14
- attr_reader :warnings, :removed_files
15
+ # removed_files: the bundle's own hooks/ and plugin/ files (deleted);
16
+ # trashed_files: its memory files, moved to trash_dir (Trash), nil
17
+ # when none was moved.
18
+ attr_reader :warnings, :removed_files, :trashed_files
15
19
 
16
20
  def initialize(name:, scope: nil, force: false)
17
21
  @name = name
@@ -19,8 +23,12 @@ module Samagotchi
19
23
  @force = force
20
24
  @warnings = []
21
25
  @removed_files = []
26
+ @trashed_files = []
27
+ @trash = Trash.new(name)
22
28
  end
23
29
 
30
+ def trash_dir = @trash.dir
31
+
24
32
  def run
25
33
  provenance = Provenance.new(name: @name)
26
34
  data = provenance.read
@@ -40,12 +48,18 @@ module Samagotchi
40
48
  return true
41
49
  end
42
50
 
51
+ # A file another installed bundle lists too stays, edited or not.
52
+ shared = files.keys.map(&:to_s).to_h do |key|
53
+ [key, Provenance.claimants(key, scope: target_scope, except: @name)]
54
+ end.reject { |_, others| others.empty? }
55
+
43
56
  blocked = []
44
57
  files.each do |file_key, meta|
45
58
  file_key_str = file_key.to_s
46
59
  target_path = File.join(target_dir, file_key_str)
47
60
  base_path = provenance.base_path(file_key_str)
48
61
  next unless File.exist?(target_path)
62
+ next if shared.key?(file_key_str)
49
63
  if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
50
64
  blocked << file_key_str
51
65
  @warnings << "Skipped #{file_key_str}: local edits detected (use --force to remove)"
@@ -57,15 +71,23 @@ module Samagotchi
57
71
  files.each do |file_key, _meta|
58
72
  file_key_str = file_key.to_s
59
73
  target_path = File.join(target_dir, file_key_str)
74
+ if (others = shared[file_key_str])
75
+ next unless File.exist?(target_path)
76
+
77
+ @warnings << "Kept #{file_key_str}: bundle #{others.join(", ")} has it too"
78
+ retag(target_scope, target_path, file_key_str, others.first)
79
+ next
80
+ end
60
81
  if File.exist?(target_path)
61
- FileUtils.rm_f(target_path)
62
- @removed_files << file_key_str
82
+ @trash.move(target_path)
83
+ @trashed_files << file_key_str
63
84
  end
64
- # Remove index line
85
+ # Remove index line (not fatal: a warning says so)
65
86
  begin
66
87
  IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"))
67
88
  IndexUpdater.remove_index(target_scope, file_key_str) # legacy "name.md" line
68
- rescue => _e
89
+ rescue StandardError => e
90
+ @warnings << "index.md: line for #{file_key_str.delete_suffix(".md")} not removed (#{e.message})"
69
91
  end
70
92
  end
71
93
 
@@ -97,6 +119,13 @@ module Samagotchi
97
119
 
98
120
  private
99
121
 
122
+ # A kept shared file's index line names the bundle that still has it.
123
+ def retag(scope, path, file_key, bundle)
124
+ IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle)
125
+ rescue StandardError => e
126
+ @warnings << "index.md: line for #{file_key.delete_suffix(".md")} not updated (#{e.message})"
127
+ end
128
+
100
129
  def resolve_target_dir(scope)
101
130
  MemoryPaths.scope_dir(scope) or raise UninstallError, "invalid scope: #{scope}"
102
131
  end
@@ -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
@@ -18,11 +18,14 @@ module Samagotchi
18
18
  # (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
19
19
  # the answer text.
20
20
  # +messages_partial+ says whether +messages+ leaves out a running turn;
21
- # +model_name+ and +state_dir+ are what ctx.sessions forks with.
21
+ # +model_name+ (the session's resolved model ref, live after /model) and
22
+ # +state_dir+ are what ctx.sessions forks with; +model_key+ is the
23
+ # model's memory overlay key (ctx.model, ctx.model_key).
22
24
  # +steer+ takes (text, label), +stop_turn+ and +stop_generation+
23
25
  # (reason, label), each true when it acted on a running turn.
24
26
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
25
- :ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, :stop_generation, keyword_init: true)
27
+ :ask_model, :model_name, :model_key, :state_dir, :scratch, :steer, :stop_turn, :stop_generation,
28
+ keyword_init: true)
26
29
 
27
30
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
28
31
  # sent nothing usable. The message says why, for the user.
@@ -86,6 +89,15 @@ module Samagotchi
86
89
  # @return [String, nil] the session's id (nil before the first one)
87
90
  def session_id = @host.session_id.call
88
91
 
92
+ # @return [String, nil] the model the session runs on now: its
93
+ # resolved ref ("host:id"), right after /model too
94
+ def model = @host.model_name&.call
95
+
96
+ # @return [String, nil] the model's memory overlay key
97
+ # (`<name>.<key>.md`, ModelOverlay.key_for), as memory_write
98
+ # current_model_only names it
99
+ def model_key = @host.model_key&.call
100
+
89
101
  # @return [String] the session's working directory
90
102
  def cwd = @host.cwd.call || Dir.pwd
91
103