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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -1
- data/docs/architecture.md +25 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +4 -2
- data/docs/guardrails.md +51 -8
- data/docs/hooks.md +4 -1
- data/docs/memory.md +5 -3
- data/docs/plugins.md +3 -2
- data/docs/sessions.md +6 -5
- data/docs/sub-agent.md +42 -4
- data/lib/samagotchi/answer_command.rb +23 -2
- data/lib/samagotchi/answer_tail.rb +52 -0
- data/lib/samagotchi/bridge.rb +49 -8
- data/lib/samagotchi/bridge_client.rb +18 -3
- data/lib/samagotchi/bundle_command.rb +108 -2
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
- data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/commands/registry.rb +28 -0
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/continue_offer.rb +177 -0
- data/lib/samagotchi/engine.rb +42 -14
- data/lib/samagotchi/guardrails/outside.rb +46 -0
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
- data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
- data/lib/samagotchi/guardrails/rules.rb +17 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
- data/lib/samagotchi/guardrails/targets.rb +30 -13
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/iteration_limit.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/backend.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +2 -1
- data/lib/samagotchi/llm/native_backend.rb +2 -1
- data/lib/samagotchi/memory_bundle/trash.rb +81 -2
- data/lib/samagotchi/parent_report.rb +39 -9
- data/lib/samagotchi/question_desk.rb +205 -46
- data/lib/samagotchi/reply_wait.rb +10 -4
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +3 -2
- data/lib/samagotchi/session_commands.rb +3 -2
- data/lib/samagotchi/session_manager.rb +4 -1
- data/lib/samagotchi/sessions_command.rb +3 -2
- data/lib/samagotchi/system_prompt.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +25 -5
- data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
- data/lib/samagotchi/terminal_ui.rb +11 -2
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/delegate_wait.rb +14 -5
- data/lib/samagotchi/tools/edit.rb +6 -1
- data/lib/samagotchi/tools/execute.rb +5 -4
- data/lib/samagotchi/tools/read.rb +2 -1
- data/lib/samagotchi/turn_flow.rb +8 -7
- data/lib/samagotchi/turn_note.rb +7 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +187 -42
- data/lib/samagotchi/web/public/app.js +122 -25
- data/lib/samagotchi/web/public/data.js +12 -13
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/notify.js +5 -2
- data/lib/samagotchi/web/public/question_card.js +28 -1
- data/lib/samagotchi/web/public/question_cards.js +74 -3
- data/lib/samagotchi/web/public/sessions_list.js +1 -0
- data/lib/samagotchi/web/public/timing.js +41 -0
- data/lib/samagotchi/web/public/turn_events.js +61 -0
- data/lib/samagotchi/web/public/update_notice.js +15 -0
- data/lib/samagotchi/worker.rb +30 -58
- data/lib/samagotchi/worker_sidecar.rb +6 -3
- 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 "
|
|
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
|
|
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
|
|
127
|
-
#
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
13
|
-
#
|
|
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
|