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
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Guardrails
5
+ # Where a shell command runs a git subcommand that changes a checkout
6
+ # (commit, add, reset, …): the directories, or :unknown for one that
7
+ # can't be read from the text ($VAR, $(…), `cd -`). Pure text: it
8
+ # follows cd/pushd/popd, ( … ) subshells, git -C/--git-dir/--work-tree
9
+ # and GIT_DIR/GIT_WORK_TREE. It catches honest mistakes (cd into
10
+ # another checkout and commit there); sh -c, scripts, aliases and
11
+ # $(…) bodies get through. Never raises on odd input.
12
+ module ShellGitDirs
13
+ MUTATING = %w[commit add reset checkout switch rebase merge push stash rm mv
14
+ cherry-pick revert pull restore am].freeze
15
+ STASH_READS = %w[list show].freeze
16
+ # Longest first: "&&" before "&", ";;" before ";".
17
+ OPERATORS = ["&&", "||", ";;", "|&", ";", "|", "&", "\n", "(", ")"].freeze
18
+ PREFIXES = %w[command builtin exec time nohup env ! { if then else elif do while until].freeze
19
+ # git global options that take the next word as their value.
20
+ VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace].freeze
21
+ SUBST = "\0SUBST"
22
+ REDIRECT = /\A\d*[<>]/
23
+
24
+ # @param command [String] the shell command
25
+ # @param cwd [String] where it starts (the call's cwd:, else the session's)
26
+ # @param home [String] for ~, $HOME, ${HOME} and a bare cd
27
+ # @return [Array<String, Symbol>] absolute dirs and :unknown, unique
28
+ def self.for(command, cwd:, home: Dir.home)
29
+ Walk.new(cwd, home).run(simple_commands(lex(command.to_s)))
30
+ rescue ArgumentError, EncodingError
31
+ [] # a NUL byte, invalid UTF-8: the shell would refuse it too
32
+ end
33
+
34
+ # Words and operators ([:word, "x"] / [:op, "&&"]).
35
+ def self.lex(text)
36
+ Lexer.new(text).tokens
37
+ end
38
+
39
+ # The token stream split at operators into word lists; ( and ) become
40
+ # :open / :close markers.
41
+ def self.simple_commands(tokens)
42
+ commands = [[]]
43
+ tokens.each do |kind, value|
44
+ if kind == :op
45
+ commands << :open if value == "("
46
+ commands << :close if value == ")"
47
+ commands << []
48
+ else
49
+ commands.last << value
50
+ end
51
+ end
52
+ commands.reject { |c| c.is_a?(Array) && c.empty? }
53
+ end
54
+
55
+ # A hand-written shell lexer: quotes, backslashes, comments, $(…) and
56
+ # backticks (an opaque marker inside the word), redirections kept in
57
+ # their word (2>&1 doesn't split on &), operators.
58
+ class Lexer
59
+ def initialize(text)
60
+ @s = text
61
+ @i = 0
62
+ @tokens = []
63
+ @word = +""
64
+ @in_word = false
65
+ end
66
+
67
+ def tokens
68
+ step while @i < @s.size
69
+ flush
70
+ @tokens
71
+ end
72
+
73
+ private
74
+
75
+ def step
76
+ c = @s[@i]
77
+ case c
78
+ when "\\" then add(@s[@i + 1].to_s, 2)
79
+ when "'" then single_quote
80
+ when '"' then double_quote
81
+ when "`" then backtick
82
+ when "$" then @s[@i + 1] == "(" ? substitution : add(c, 1)
83
+ when "#" then @in_word ? add(c, 1) : comment
84
+ when ">", "<" then redirection
85
+ when " ", "\t" then flush && (@i += 1)
86
+ else operator_or_char(c)
87
+ end
88
+ end
89
+
90
+ def add(text, advance)
91
+ @word << text
92
+ @in_word = true
93
+ @i += advance
94
+ end
95
+
96
+ def flush
97
+ @tokens << [:word, @word.dup] if @in_word
98
+ @word.clear
99
+ @in_word = false
100
+ true
101
+ end
102
+
103
+ def single_quote
104
+ close = @s.index("'", @i + 1) || @s.size
105
+ add(@s[(@i + 1)...close].to_s, close + 1 - @i)
106
+ end
107
+
108
+ def double_quote
109
+ @i += 1
110
+ @in_word = true
111
+ while @i < @s.size && @s[@i] != '"'
112
+ if @s[@i] == "\\" && @i + 1 < @s.size
113
+ @word << @s[@i + 1]
114
+ @i += 2
115
+ else
116
+ @word << @s[@i]
117
+ @i += 1
118
+ end
119
+ end
120
+ @i += 1
121
+ end
122
+
123
+ def backtick
124
+ close = @s.index("`", @i + 1) || @s.size
125
+ add(SUBST, close + 1 - @i)
126
+ end
127
+
128
+ def substitution
129
+ depth = 0
130
+ j = @i + 1
131
+ loop do
132
+ depth += 1 if @s[j] == "("
133
+ depth -= 1 if @s[j] == ")"
134
+ j += 1
135
+ break if depth.zero? || j >= @s.size
136
+ end
137
+ add(SUBST, j - @i)
138
+ end
139
+
140
+ def comment
141
+ @i = @s.index("\n", @i) || @s.size
142
+ end
143
+
144
+ # >, >>, 2>&1, &>, <, <<: kept in the word (the walk drops them).
145
+ def redirection
146
+ add(@s[@i], 1)
147
+ add(@s[@i], 1) while @i < @s.size && @s[@i].match?(/[&\d>-]/) && @word.match?(/>\z/)
148
+ end
149
+
150
+ def operator_or_char(char)
151
+ op = OPERATORS.find { |o| @s[@i, o.size] == o }
152
+ return add(char, 1) unless op
153
+
154
+ if op == "&" && @word.match?(/\d*>\z/)
155
+ add(op, 1)
156
+ else
157
+ flush
158
+ @tokens << [:op, op]
159
+ @i += op.size
160
+ end
161
+ end
162
+ end
163
+
164
+ # Follows the current dir through the simple commands and records
165
+ # where each mutating git runs.
166
+ class Walk
167
+ def initialize(cwd, home)
168
+ @dir = cwd
169
+ @home = home
170
+ @subshells = []
171
+ @pushed = []
172
+ @found = []
173
+ end
174
+
175
+ def run(commands)
176
+ commands.each do |words|
177
+ case words
178
+ when :open then @subshells << @dir
179
+ when :close then @dir = @subshells.pop || @dir
180
+ else simple(words.dup)
181
+ end
182
+ end
183
+ @found.uniq
184
+ end
185
+
186
+ private
187
+
188
+ def simple(words)
189
+ env = {}
190
+ while (word = words.first)
191
+ if word.match?(/\A[A-Za-z_]\w*=/)
192
+ key, value = word.split("=", 2)
193
+ env[key] = value
194
+ elsif !PREFIXES.include?(word)
195
+ break
196
+ end
197
+ words.shift
198
+ end
199
+ verb = words.shift or return
200
+ args = words.grep_v(REDIRECT)
201
+ case verb
202
+ when "cd" then cd(args)
203
+ when "pushd"
204
+ @pushed << @dir
205
+ @dir = args.first ? expand(args.first, @dir) : nil
206
+ when "popd" then @dir = @pushed.pop unless @pushed.empty?
207
+ else git(args, env) if verb == "git" || verb.end_with?("/git")
208
+ end
209
+ end
210
+
211
+ def cd(args)
212
+ target = args.reject { |a| %w[-P -L -e -@].include?(a) }.first
213
+ @dir = if target.nil? then @home
214
+ elsif target == "-" then nil
215
+ else expand(target, @dir)
216
+ end
217
+ end
218
+
219
+ def git(args, env)
220
+ dir = @dir
221
+ work_tree = env["GIT_WORK_TREE"] && expand(env["GIT_WORK_TREE"], dir)
222
+ git_dir = env["GIT_DIR"] && expand(env["GIT_DIR"], dir)
223
+ sub = nil
224
+ until args.empty?
225
+ arg = args.shift
226
+ case arg
227
+ when *VALUE_OPTIONS
228
+ value = args.shift.to_s
229
+ dir = expand(value, dir) if arg == "-C"
230
+ git_dir = expand(value, dir) if arg == "--git-dir"
231
+ work_tree = expand(value, dir) if arg == "--work-tree"
232
+ when /\A--git-dir=(.*)\z/m then git_dir = expand(Regexp.last_match(1), dir)
233
+ when /\A--work-tree=(.*)\z/m then work_tree = expand(Regexp.last_match(1), dir)
234
+ when /\A-/ then next
235
+ else
236
+ sub = arg
237
+ break
238
+ end
239
+ end
240
+ return unless MUTATING.include?(sub)
241
+ return if sub == "stash" && STASH_READS.include?(args.first)
242
+
243
+ @found << (work_tree || repo_of(git_dir) || dir || :unknown)
244
+ end
245
+
246
+ def repo_of(git_dir)
247
+ return nil unless git_dir
248
+
249
+ File.basename(git_dir) == ".git" ? File.dirname(git_dir) : git_dir
250
+ end
251
+
252
+ # An absolute path for +arg+ against +dir+; nil when it can't be
253
+ # told from the text (a variable, a substitution, a relative path
254
+ # against an unknown dir).
255
+ def expand(arg, dir)
256
+ return nil if arg.include?(SUBST)
257
+
258
+ arg = arg.sub(/\A(~|\$HOME|\$\{HOME\})(?=\/|\z)/) { @home }
259
+ return nil if arg.include?("$") || arg.start_with?("~")
260
+ return File.expand_path(arg) if arg.start_with?("/")
261
+
262
+ dir && File.expand_path(arg, dir)
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end
@@ -4,20 +4,27 @@ require_relative "../tools/tool_path"
4
4
  require_relative "../tools/memory"
5
5
  require_relative "../log"
6
6
  require_relative "model_size"
7
- require_relative "../memory_bundle/index_sync"
7
+ require_relative "outside"
8
+ require_relative "shell_git_dirs"
8
9
 
9
10
  module Samagotchi
10
11
  module Guardrails
11
12
  # What a tool call acts on, resolved the way the tools resolve it: the
12
13
  # shell command, absolute paths (against Dir.pwd, as the file tools have
13
14
  # no cwd:), the directory a command runs in, its repo root, and whether
14
- # a path leaves the repo (the cwd when there is no repo).
15
+ # a path leaves the session's repo (Outside: the context cwd's repo
16
+ # root, the cwd when there is no repo; not the call's own cwd:).
15
17
  class Targets
16
18
  SHELL_TOOLS = %w[execute task_create].freeze
17
19
  PATH_TOOLS = %w[write edit read memory_write].freeze
18
20
 
19
21
  attr_reader :tool, :command, :paths, :cwd, :repo_root
20
22
 
23
+ # @return [String] the session's repo root (the context cwd's; the cwd
24
+ # outside a repo): what outside_repo measures from. repo_root is the
25
+ # call's (its own cwd: for shell and plugin tools).
26
+ attr_reader :session_root
27
+
21
28
  # @return [String, nil] the effective model: its bare name (no host
22
29
  # prefix) and its key (ModelOverlay.key_for), for a rule's models:
23
30
  attr_reader :model_name, :model_key
@@ -62,7 +69,7 @@ module Samagotchi
62
69
  args = call[:args] if entry && !entry.core? && call[:args].is_a?(Hash)
63
70
  end
64
71
  new(tool: tool, command: command, paths: paths.compact, cwd: cwd, repo_root: context.repo_root(cwd), args: args,
65
- model_name: model_name, model_key: model_key)
72
+ model_name: model_name, model_key: model_key, session_root: context.repo_root(base) || base)
66
73
  end
67
74
 
68
75
  # What a plugin tool's targets: callable says the call acts on:
@@ -102,7 +109,9 @@ module Samagotchi
102
109
  nil
103
110
  end
104
111
 
105
- def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil)
112
+ def initialize(tool:, command:, paths:, cwd:, repo_root:, args: nil, model_name: nil, model_key: nil,
113
+ session_root: nil)
114
+ @session_root = session_root || repo_root || cwd
106
115
  @args = args
107
116
  @model_name = model_name
108
117
  @model_key = model_key
@@ -123,20 +132,28 @@ module Samagotchi
123
132
  @small_model = ModelSize.small?(@model_name, @model_key)
124
133
  end
125
134
 
126
- # Whether any path is outside the repo root (the cwd outside a repo).
127
- # A memory's file doesn't count (MemoryBundle::IndexSync.memory_scope):
128
- # write/edit there is what memory_write does, unasked; index.md, the
129
- # bundles dir and the rest of chi's config dir still count.
135
+ # Whether any path is outside the session's repo (Outside.outside?:
136
+ # symlinks resolved; a memory's file and tmp dirs don't count).
130
137
  def outside_repo?
131
- root = @repo_root || @cwd
132
- @paths.any? do |p|
133
- p != root && !p.start_with?(File.join(root, "")) && !MemoryBundle::IndexSync.memory_scope(p)
134
- end
138
+ @paths.any? { |p| Outside.outside?(p, root: @session_root) }
139
+ end
140
+
141
+ # Where a shell call runs mutating git (ShellGitDirs: dirs, and
142
+ # :unknown for one the text doesn't tell); [] for other tools.
143
+ def git_dirs
144
+ @git_dirs ||= shell? ? ShellGitDirs.for(@command, cwd: @cwd) : []
145
+ end
146
+
147
+ # Whether a shell call runs mutating git outside the session's repo
148
+ # (an :unknown dir doesn't count).
149
+ def git_outside_repo?
150
+ git_dirs.any? { |dir| dir.is_a?(String) && Outside.outside?(dir, root: @session_root) }
135
151
  end
136
152
 
137
153
  # The hook event's targets: hash.
138
154
  def to_h
139
- { command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo? }
155
+ { command: @command, paths: @paths, cwd: @cwd, repo_root: @repo_root, outside_repo: outside_repo?,
156
+ git_dirs: git_dirs.map(&:to_s) }
140
157
  end
141
158
  end
142
159
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "guardrails/verdict"
4
4
  require_relative "guardrails/context"
5
+ require_relative "guardrails/outside"
6
+ require_relative "guardrails/shell_git_dirs"
5
7
  require_relative "guardrails/targets"
6
8
  require_relative "guardrails/model_size"
7
9
  require_relative "guardrails/approval"
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # A turn's iteration limit (how many model ↔ tool rounds before the turn
8
+ # stops and offers to continue), one resolver for the Engine, the worker
9
+ # and the REPL.
10
+ module IterationLimit
11
+ KEY = "turn.max_iterations"
12
+ # A turn's limit unless turn.max_iterations says otherwise, and the
13
+ # loops' own default.
14
+ DEFAULT = 100
15
+ # A --no-interrupt / --non-interactive turn's, at least: nobody is there
16
+ # to answer a continue offer.
17
+ NO_INTERRUPT = 1000
18
+
19
+ # @param no_interrupt [Boolean] the turn runs with nobody to answer: the
20
+ # larger of NO_INTERRUPT and the configured limit, so a configured one
21
+ # above 1000 is never lowered
22
+ # @return [Integer]
23
+ def self.for(no_interrupt: false) = no_interrupt ? [NO_INTERRUPT, configured].max : configured
24
+
25
+ # turn.max_iterations (env SAMAGOTCHI_TURN_MAX_ITERATIONS); one below 1
26
+ # is DEFAULT, with a warning.
27
+ # @return [Integer]
28
+ def self.configured
29
+ value = Config.get(KEY)
30
+ return DEFAULT if value.nil?
31
+ return value if value.is_a?(Integer) && value >= 1
32
+
33
+ Log.warn(:config, "invalid_value", echo: "Warning: #{KEY} must be 1 or more, got #{value.inspect} — using #{DEFAULT}",
34
+ key: KEY)
35
+ DEFAULT
36
+ end
37
+ end
38
+ end
@@ -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
 
@@ -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