samagotchi 0.56.0 → 0.57.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 +36 -1
- data/README.md +1 -1
- data/docs/cli.md +1 -1
- data/docs/hooks.md +2 -2
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_registry.rb +15 -0
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/redacted_inspect.rb +28 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -5
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -4
- data/lib/samagotchi/web/public/app.js +24 -8
- data/lib/samagotchi/web/public/index.html +16 -2
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/ref_scan.js +206 -0
- data/lib/samagotchi/web/public/refs.js +19 -10
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- metadata +6 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The slow-commands bundle (docs/plugins.md, The slow-commands bundle): an
|
|
4
|
+
# `execute` that ran past its limit (or close to it) is remembered for the
|
|
5
|
+
# project, and the next identical `execute` is denied with advice: start it
|
|
6
|
+
# with task_create, then task_wait with a timeout that fits.
|
|
7
|
+
#
|
|
8
|
+
# A command is keyed by its project (the session's git checkout, else its
|
|
9
|
+
# working directory) and its text: the ShellLex words and operators joined
|
|
10
|
+
# by one space, the call's cwd: folded in as a leading `cd <dir> &&`
|
|
11
|
+
# (relative to the project), and a trailing output filter dropped (a final
|
|
12
|
+
# 2>&1, `| tail …`, `| head …`, `| grep …`: they don't change how long it
|
|
13
|
+
# runs). Arguments stay: `bundle exec rspec` (the suite) is not
|
|
14
|
+
# `bundle exec rspec spec/x_spec.rb`. Input that ends inside a quote, $(…)
|
|
15
|
+
# or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
|
|
16
|
+
# cat/tee read as data: ShellLex makes those one placeholder, so different
|
|
17
|
+
# commands would share a key.
|
|
18
|
+
#
|
|
19
|
+
# after_tool_call records: timed out, or ran at least warn_ratio of the
|
|
20
|
+
# limit -> slow; ran under half of it with exit 0 -> the entry is dropped
|
|
21
|
+
# (a fast failure says nothing about the suite's length), unless a trailing
|
|
22
|
+
# filter was stripped: `rspec | tail` exits with tail's 0 when rspec failed,
|
|
23
|
+
# and a `| head` can end it early. A stopped or
|
|
24
|
+
# denied call records nothing. A key is denied once per turn: the model
|
|
25
|
+
# running it again in the same turn is let through (maybe it got faster),
|
|
26
|
+
# and that run records the new truth. before_turn resets it.
|
|
27
|
+
#
|
|
28
|
+
# Store: <data_dir>/commands.json, a list of {project, command, slow_at,
|
|
29
|
+
# limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
|
|
30
|
+
# record counts at once), written whole (AtomicFile), entries older than
|
|
31
|
+
# ttl_days dropped on write.
|
|
32
|
+
#
|
|
33
|
+
# The advice names how long a finished task of the same command took: the
|
|
34
|
+
# project's tmp/tasks/*/task.json read as files (never TaskRuntime's
|
|
35
|
+
# list_records, which mkdirs, runs ps and writes), the exit_code file's
|
|
36
|
+
# mtime minus started_at (finished_at is when chi noticed).
|
|
37
|
+
#
|
|
38
|
+
# Settings (config.yml, bundles: slow-commands:):
|
|
39
|
+
# mode: deny deny | notify (warn the user, never deny)
|
|
40
|
+
# warn_ratio: 0.8 a run this share of the limit or longer is slow
|
|
41
|
+
# ttl_days: 14 entries older than this are forgotten
|
|
42
|
+
#
|
|
43
|
+
# /slow-commands lists this project's entries; /slow-commands forget <n|all>
|
|
44
|
+
# drops them.
|
|
45
|
+
require "json"
|
|
46
|
+
require "pathname"
|
|
47
|
+
require "time"
|
|
48
|
+
require "samagotchi/atomic_file"
|
|
49
|
+
require "samagotchi/guardrails/shell_lex"
|
|
50
|
+
require "samagotchi/tools/execute"
|
|
51
|
+
require "samagotchi/tools/task_runtime"
|
|
52
|
+
|
|
53
|
+
class Plugin
|
|
54
|
+
SOURCE = "bundle slow-commands"
|
|
55
|
+
FILE = "commands.json"
|
|
56
|
+
FILTERS = %w[tail head grep].freeze
|
|
57
|
+
TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
|
|
58
|
+
STOPPED = [Samagotchi::Tools::Execute::STOPPED_BY_USER, Samagotchi::Tools::Execute::NOT_RUN_ON_STOP].freeze
|
|
59
|
+
LEX = Samagotchi::Guardrails::ShellLex
|
|
60
|
+
SHORT_CHARS = 60
|
|
61
|
+
WAIT_DEFAULT = 600
|
|
62
|
+
|
|
63
|
+
def initialize(settings = {})
|
|
64
|
+
@mode = settings["mode"].to_s == "notify" ? :notify : :deny
|
|
65
|
+
ratio = Float(settings["warn_ratio"].to_s, exception: false)
|
|
66
|
+
@warn_ratio = ratio && ratio.positive? && ratio <= 1 ? ratio : 0.8
|
|
67
|
+
@ttl_days = positive(settings["ttl_days"]) || 14
|
|
68
|
+
reset
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def register(chi)
|
|
72
|
+
chi.on(:before_turn) { |_event| reset }
|
|
73
|
+
chi.on(:before_tool_call) { |event, ctx| before(event, ctx) }
|
|
74
|
+
chi.on(:after_tool_call) { |event, ctx| after(event, ctx) }
|
|
75
|
+
chi.command "/slow-commands", "commands that ran past the execute limit here; forget <n|all> drops them",
|
|
76
|
+
anytime: true do |args, ctx|
|
|
77
|
+
command(args.to_s.strip, ctx)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The command text as the store keys it, or nil when it can't be keyed
|
|
82
|
+
# (empty, unterminated). +cwd+: where it runs (nil: the session's).
|
|
83
|
+
def self.normalize(text, cwd:, project:, base:)
|
|
84
|
+
text = text.to_s.strip
|
|
85
|
+
return nil if text.empty? || LEX.unterminated?(text)
|
|
86
|
+
|
|
87
|
+
tokens = LEX.lex(text)
|
|
88
|
+
return nil if tokens.any? { |kind, value| kind == :word && value.include?("\0") }
|
|
89
|
+
|
|
90
|
+
tokens = strip_filters(tokens)
|
|
91
|
+
return nil if tokens.empty?
|
|
92
|
+
|
|
93
|
+
words = tokens.map { |kind, value| kind == :op ? value.tr("\n", ";") : quote(value) }
|
|
94
|
+
dir = folded_dir(cwd, project: project, base: base)
|
|
95
|
+
words = ["cd", quote(dir), "&&", *words] if dir
|
|
96
|
+
words.join(" ")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Whether the key dropped a trailing output filter (2>&1, | tail …):
|
|
100
|
+
# the exit code is then the filter's, and the run may have been cut short.
|
|
101
|
+
def self.filtered?(text)
|
|
102
|
+
tokens = LEX.lex(text.to_s.strip)
|
|
103
|
+
tokens.pop while tokens.last && tokens.last[0] == :op && [";", "\n"].include?(tokens.last[1])
|
|
104
|
+
strip_filters(tokens).size != tokens.size
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Drops a trailing 2>&1 word and `| tail/head/grep …` filters, and a
|
|
108
|
+
# trailing ; or newline, until none is left.
|
|
109
|
+
def self.strip_filters(tokens)
|
|
110
|
+
tokens = tokens.dup
|
|
111
|
+
loop do
|
|
112
|
+
before = tokens.size
|
|
113
|
+
tokens.pop while tokens.last && tokens.last[0] == :op && [";", "\n"].include?(tokens.last[1])
|
|
114
|
+
tokens.pop if tokens.last && tokens.last[0] == :word && tokens.last[1] == "2>&1"
|
|
115
|
+
pipe = tokens.rindex { |kind, value| kind == :op && value == "|" }
|
|
116
|
+
if pipe && tokens[(pipe + 1)..].all? { |kind, _| kind == :word } && FILTERS.include?(tokens[pipe + 1]&.at(1))
|
|
117
|
+
tokens = tokens[0...pipe]
|
|
118
|
+
end
|
|
119
|
+
return tokens if tokens.size == before
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.quote(word)
|
|
124
|
+
word.empty? || word.match?(/[\s'"]/) ? "'#{word.gsub("'", "'\\\\''")}'" : word
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The run dir relative to +project+, or nil for the project itself.
|
|
128
|
+
def self.folded_dir(cwd, project:, base:)
|
|
129
|
+
dir = File.expand_path(cwd.to_s.strip.empty? ? base : cwd.to_s.strip, base)
|
|
130
|
+
rel = Pathname.new(dir).relative_path_from(Pathname.new(File.expand_path(project))).to_s
|
|
131
|
+
rel == "." ? nil : rel
|
|
132
|
+
rescue ArgumentError
|
|
133
|
+
dir
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def reset
|
|
139
|
+
@denied = {}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def before(event, ctx)
|
|
143
|
+
call = event[:call]
|
|
144
|
+
verdict = event[:guardrail]
|
|
145
|
+
return unless call.is_a?(Hash) && call[:name].to_s == Samagotchi::Tools::Execute::NAME && verdict
|
|
146
|
+
return if verdict.deny?
|
|
147
|
+
|
|
148
|
+
key = key_for(call, ctx)
|
|
149
|
+
return unless key
|
|
150
|
+
return if @denied[key]
|
|
151
|
+
|
|
152
|
+
entry = live_entries(ctx).find { |e| e["project"] == key[0] && e["command"] == key[1] }
|
|
153
|
+
return unless entry
|
|
154
|
+
|
|
155
|
+
@denied[key] = true
|
|
156
|
+
if @mode == :notify
|
|
157
|
+
ctx.notify("execute #{short(key[1])}: #{what(entry)} before", level: :warn)
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
verdict.deny!("slow command", source: SOURCE, advice: advice(entry, ctx, key))
|
|
162
|
+
ctx.notify("execute #{short(key[1])}: #{what(entry)} before, denied", level: :warn)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def after(event, ctx)
|
|
166
|
+
call = event[:call]
|
|
167
|
+
return unless event[:tool].to_s == Samagotchi::Tools::Execute::NAME && call.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
elapsed_ms = event[:elapsed_ms]
|
|
170
|
+
result = event[:result].to_s
|
|
171
|
+
return if elapsed_ms.nil? || STOPPED.any? { |text| result.start_with?(text) }
|
|
172
|
+
|
|
173
|
+
key = key_for(call, ctx)
|
|
174
|
+
return unless key
|
|
175
|
+
|
|
176
|
+
limit = Samagotchi::Tools::Execute.timeout_seconds
|
|
177
|
+
if (timed_out = result[TIMED_OUT, 1])
|
|
178
|
+
remember(ctx, key, limit_s: timed_out.to_i, elapsed_s: timed_out.to_i, timed_out: true)
|
|
179
|
+
elsif elapsed_ms >= @warn_ratio * limit * 1000
|
|
180
|
+
remember(ctx, key, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
|
|
181
|
+
elsif elapsed_ms < 0.5 * limit * 1000 && exit_zero?(result) && !self.class.filtered?(call[:content])
|
|
182
|
+
forget_key(ctx, key)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def key_for(call, ctx)
|
|
187
|
+
project = project_of(ctx)
|
|
188
|
+
command = self.class.normalize(call[:content], cwd: call[:cwd], project: project, base: ctx.cwd)
|
|
189
|
+
command ? [project, command] : nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def project_of(ctx) = ctx.repo_root || ctx.cwd
|
|
193
|
+
|
|
194
|
+
def exit_zero?(result)
|
|
195
|
+
result.scan(/^exit: (-?\d+)/).last == ["0"]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- the store ---
|
|
199
|
+
|
|
200
|
+
def store_path(ctx) = File.join(ctx.data_dir, FILE)
|
|
201
|
+
|
|
202
|
+
def entries(ctx)
|
|
203
|
+
list = JSON.parse(File.read(store_path(ctx)))
|
|
204
|
+
list.is_a?(Array) ? list.select { |e| e.is_a?(Hash) && e["project"] && e["command"] } : []
|
|
205
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
206
|
+
[]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def live_entries(ctx) = entries(ctx).select { |e| live?(e) }
|
|
210
|
+
|
|
211
|
+
def live?(entry)
|
|
212
|
+
Time.iso8601(entry["slow_at"].to_s) > Time.now - (@ttl_days * 86_400)
|
|
213
|
+
rescue ArgumentError
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def save(ctx, list)
|
|
218
|
+
Samagotchi::AtomicFile.write(store_path(ctx), "#{JSON.pretty_generate(list.select { |e| live?(e) })}\n")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def remember(ctx, key, limit_s:, elapsed_s:, timed_out:)
|
|
222
|
+
list = entries(ctx).reject { |e| e["project"] == key[0] && e["command"] == key[1] }
|
|
223
|
+
list << { "project" => key[0], "command" => key[1], "slow_at" => Time.now.utc.iso8601,
|
|
224
|
+
"limit_s" => limit_s, "elapsed_s" => elapsed_s, "timed_out" => timed_out }
|
|
225
|
+
save(ctx, list)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def forget_key(ctx, key)
|
|
229
|
+
list = entries(ctx)
|
|
230
|
+
kept = list.reject { |e| e["project"] == key[0] && e["command"] == key[1] }
|
|
231
|
+
save(ctx, kept) if kept.size != list.size
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# --- the deny's advice ---
|
|
235
|
+
|
|
236
|
+
def advice(entry, ctx, key)
|
|
237
|
+
took = task_duration(ctx, key)
|
|
238
|
+
wait = took ? [WAIT_DEFAULT, ((took * 1.5) / 60.0).ceil * 60].max : WAIT_DEFAULT
|
|
239
|
+
date = entry["slow_at"].to_s[0, 10]
|
|
240
|
+
seen = if entry["timed_out"]
|
|
241
|
+
"This command ran past the execute limit (#{entry["limit_s"]} s) here on #{date}."
|
|
242
|
+
else
|
|
243
|
+
"This command took #{duration(entry["elapsed_s"])} of the execute limit (#{entry["limit_s"]} s) here on #{date}."
|
|
244
|
+
end
|
|
245
|
+
text = "#{seen} Start it with task_create, then task_wait (timeout #{wait})."
|
|
246
|
+
took ? "#{text} A finished task of this command took #{duration(took)}." : text
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Seconds the newest finished (exit 0) task of this key took, or nil.
|
|
250
|
+
def task_duration(ctx, key)
|
|
251
|
+
roots = [ctx.cwd, project_of(ctx)].uniq
|
|
252
|
+
records = roots.flat_map do |root|
|
|
253
|
+
Dir.glob(File.join(root, Samagotchi::Tools::TaskRuntime::TASKS_DIR, "*", "task.json"))
|
|
254
|
+
end.uniq
|
|
255
|
+
found = records.filter_map { |path| finished_task(path, ctx, key) }
|
|
256
|
+
found.max_by(&:first)&.last
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# [started_at, seconds] for a task record of +key+ that ended with exit 0.
|
|
260
|
+
def finished_task(path, ctx, key)
|
|
261
|
+
record = JSON.parse(File.read(path))
|
|
262
|
+
exit_code = File.join(File.dirname(path), Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME)
|
|
263
|
+
return nil unless File.file?(exit_code) && File.read(exit_code).strip == "0"
|
|
264
|
+
|
|
265
|
+
command = self.class.normalize(record["command"], cwd: record["cwd"], project: key[0], base: ctx.cwd)
|
|
266
|
+
return nil unless command == key[1]
|
|
267
|
+
|
|
268
|
+
started = Time.iso8601(record["started_at"].to_s)
|
|
269
|
+
seconds = (File.mtime(exit_code) - started).round
|
|
270
|
+
seconds.negative? ? nil : [started, seconds]
|
|
271
|
+
rescue StandardError
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# --- /slow-commands ---
|
|
276
|
+
|
|
277
|
+
def command(args, ctx)
|
|
278
|
+
project = project_of(ctx)
|
|
279
|
+
mine = live_entries(ctx).select { |e| e["project"] == project }.sort_by { |e| e["slow_at"].to_s }
|
|
280
|
+
case args
|
|
281
|
+
when ""
|
|
282
|
+
return "slow-commands: none recorded for #{project}" if mine.empty?
|
|
283
|
+
|
|
284
|
+
lines = mine.each_with_index.map do |e, i|
|
|
285
|
+
"#{i + 1}. #{e["command"]}: #{what(e)} (#{e["slow_at"].to_s[0, 10]})"
|
|
286
|
+
end
|
|
287
|
+
"slow-commands in #{project}:\n#{lines.join("\n")}\n/slow-commands forget <n|all> drops an entry"
|
|
288
|
+
when /\Aforget\s+(all|\d+)\z/
|
|
289
|
+
which = Regexp.last_match(1)
|
|
290
|
+
drop = which == "all" ? mine : [mine[which.to_i - 1]].compact
|
|
291
|
+
return "slow-commands: no entry #{which} (/slow-commands lists them)" if drop.empty?
|
|
292
|
+
|
|
293
|
+
save(ctx, entries(ctx).reject { |e| drop.any? { |d| d["project"] == e["project"] && d["command"] == e["command"] } })
|
|
294
|
+
"slow-commands: forgot #{drop.size == 1 ? drop.first["command"] : "#{drop.size} commands"}"
|
|
295
|
+
else
|
|
296
|
+
"usage: /slow-commands (list this project's slow commands), /slow-commands forget <n|all>"
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# --- text ---
|
|
301
|
+
|
|
302
|
+
def what(entry)
|
|
303
|
+
entry["timed_out"] ? "ran past #{entry["limit_s"]} s" : "took #{duration(entry["elapsed_s"])} of #{entry["limit_s"]} s"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def duration(seconds)
|
|
307
|
+
seconds = seconds.to_i
|
|
308
|
+
seconds < 60 ? "#{seconds}s" : "#{seconds / 60}m#{format("%02d", seconds % 60)}s"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def short(command)
|
|
312
|
+
text = command.to_s
|
|
313
|
+
text.length > SHORT_CHARS ? "#{text[0, SHORT_CHARS - 1]}…" : text
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def positive(value)
|
|
317
|
+
number = Integer(value.to_s, exception: false)
|
|
318
|
+
number&.positive? ? number : nil
|
|
319
|
+
end
|
|
320
|
+
end
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.57.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
9
|
config_modification_protocol.md: sha256:d908921b5988244fb2a03fe04e693d0e7a1dfadf8cd77704e9232826db7eb2d7
|
|
10
|
-
memory_guide.md: sha256:
|
|
10
|
+
memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
|
@@ -94,7 +94,7 @@ Bundles are versioned directories/zips/tar.gz/git URLs with a `manifest.yml` and
|
|
|
94
94
|
- Integrity: each file's sha256 is recorded at install; a hook/plugin/rule file changed afterwards is not loaded (rules: every call denied) until reinstalled. It is an integrity check, not proof of authorship.
|
|
95
95
|
- Installing = trusting its Ruby code (hooks, plugin), like a gem. Install only copies; the code runs at the next session start (`Engine.new`), so a running worker needs a restart to pick it up.
|
|
96
96
|
- A bundle without `*.md` (btw, mcp, loop-guard) adds no line to the prompt's memory index.
|
|
97
|
-
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
97
|
+
- Profiles are shipped bundles with only `includes:` (bundle names): `core` (loop-guard, check-in, guardrails, slow-commands; `chi bootstrap` installs it) and `dev` (known-names, mcp, btw, skills, source-links, github-pr, coordinator). `chi bundle install core` installs the ones not installed and records them; a bundle the user uninstalled stays out on a later install, upgrade or `chi update`, which install only bundles new to the profile. `chi bundle uninstall core` removes its recorded bundles, then core (`docs/memory.md` "Bundle profiles").
|
|
98
98
|
|
|
99
99
|
### CLI — `chi bundle`
|
|
100
100
|
|
|
@@ -10,6 +10,7 @@ require_relative "session"
|
|
|
10
10
|
require_relative "cli/command"
|
|
11
11
|
require_relative "cli/exit"
|
|
12
12
|
require_relative "cli/flags"
|
|
13
|
+
require_relative "redacted_inspect"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# `chi context`: attach live external text to sessions (ContextSources).
|
|
@@ -18,6 +19,7 @@ module Samagotchi
|
|
|
18
19
|
# reads the text with context_read.
|
|
19
20
|
class ContextCommand
|
|
20
21
|
include CLI::Command
|
|
22
|
+
include RedactedInspect
|
|
21
23
|
|
|
22
24
|
USAGE = <<~TEXT
|
|
23
25
|
Usage: chi context <add|push|ls|show|refresh|rm|mute|unmute> [options] [TARGET]
|
|
@@ -11,6 +11,7 @@ require_relative "../version"
|
|
|
11
11
|
require_relative "../self_report"
|
|
12
12
|
require_relative "../installed_gem"
|
|
13
13
|
require_relative "../config"
|
|
14
|
+
require_relative "../redacted_inspect"
|
|
14
15
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
module Desktop
|
|
@@ -22,6 +23,8 @@ module Samagotchi
|
|
|
22
23
|
# command goes through the runner, and the dirs are injectable, so specs
|
|
23
24
|
# (and smokes with register: false) never touch the real system.
|
|
24
25
|
class MacOS
|
|
26
|
+
include RedactedInspect
|
|
27
|
+
|
|
25
28
|
class Error < StandardError
|
|
26
29
|
end
|
|
27
30
|
|
|
@@ -6,6 +6,7 @@ require "open3"
|
|
|
6
6
|
require "rbconfig"
|
|
7
7
|
require "rubygems/package"
|
|
8
8
|
require_relative "version"
|
|
9
|
+
require_relative "redacted_inspect"
|
|
9
10
|
|
|
10
11
|
module Samagotchi
|
|
11
12
|
# The gem part of `chi update`: the newest samagotchi on rubygems, and a
|
|
@@ -15,6 +16,8 @@ module Samagotchi
|
|
|
15
16
|
# SAMAGOTCHI_UPDATE_GEM_FILE=<path to a .gem> (hidden, for smokes) makes that
|
|
16
17
|
# file the newest version and installs it instead.
|
|
17
18
|
class GemUpdate
|
|
19
|
+
include RedactedInspect
|
|
20
|
+
|
|
18
21
|
class Error < StandardError
|
|
19
22
|
end
|
|
20
23
|
|
|
@@ -33,6 +33,14 @@ module Samagotchi
|
|
|
33
33
|
Lexer.new(text).tokens
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# Whether +text+ ends inside a quote, a $(…) or a heredoc: its tokens
|
|
37
|
+
# are a best guess.
|
|
38
|
+
def self.unterminated?(text)
|
|
39
|
+
lexer = Lexer.new(text)
|
|
40
|
+
lexer.tokens
|
|
41
|
+
lexer.unterminated?
|
|
42
|
+
end
|
|
43
|
+
|
|
36
44
|
# The token stream split at operators into word lists; ( and ) become
|
|
37
45
|
# :open / :close markers.
|
|
38
46
|
def self.simple_commands(tokens)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "shell_lex"
|
|
4
4
|
require_relative "protected_paths"
|
|
5
|
+
require_relative "../redacted_inspect"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
module Guardrails
|
|
@@ -62,6 +63,8 @@ module Samagotchi
|
|
|
62
63
|
# Follows the current dir and gives, per simple command, each word
|
|
63
64
|
# with its path (nil when it can't be resolved).
|
|
64
65
|
class Walk
|
|
66
|
+
include RedactedInspect
|
|
67
|
+
|
|
65
68
|
def initialize(cwd, home, env)
|
|
66
69
|
@dir = cwd
|
|
67
70
|
@home = home
|
|
@@ -384,6 +384,21 @@ module Samagotchi
|
|
|
384
384
|
@mutex.synchronize { @cache }
|
|
385
385
|
end
|
|
386
386
|
|
|
387
|
+
# Hosts, their addresses, key variable names and declared models, never
|
|
388
|
+
# the environment: it holds every host's API key (a failing spec prints
|
|
389
|
+
# the registry).
|
|
390
|
+
def inspect
|
|
391
|
+
hosts = @entries.values.map do |entry|
|
|
392
|
+
parts = [entry.openai_base_url]
|
|
393
|
+
parts << "key=#{entry.api_key_env}" if entry.api_key_env
|
|
394
|
+
parts << "models=#{entry.models.values.map(&:id).join(",")}" unless entry.models.empty?
|
|
395
|
+
"#{entry.name}(#{parts.join(" ")})"
|
|
396
|
+
end
|
|
397
|
+
"#<#{self.class.name} #{hosts.join(", ")}>"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
401
|
+
|
|
387
402
|
private
|
|
388
403
|
|
|
389
404
|
# A listing error as text without the host's name (a provider error's
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "installed_gem"
|
|
4
4
|
require_relative "version"
|
|
5
|
+
require_relative "redacted_inspect"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# The newest chi on this machine, which a long-lived process (chi web, an
|
|
@@ -18,6 +19,8 @@ module Samagotchi
|
|
|
18
19
|
# SAMAGOTCHI_INSTALLED_VERSION=<v> (hidden, for smokes and e2e) answers
|
|
19
20
|
# <v> instead.
|
|
20
21
|
class InstalledVersions
|
|
22
|
+
include RedactedInspect
|
|
23
|
+
|
|
21
24
|
ENV_KEY = "SAMAGOTCHI_INSTALLED_VERSION"
|
|
22
25
|
GEMSPEC = /\Asamagotchi-(#{Gem::Version::VERSION_PATTERN})\.gemspec\z/
|
|
23
26
|
VERSION_LINE = /^\s*VERSION\s*=\s*["']([^"']+)["']/
|
|
@@ -90,6 +90,11 @@ module Samagotchi
|
|
|
90
90
|
@data_dir = nil
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# The bundle, never the environment it holds (every API key in it).
|
|
94
|
+
def inspect = "#<#{self.class.name} bundle=#{@bundle}>"
|
|
95
|
+
|
|
96
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
97
|
+
|
|
93
98
|
# @return [String, nil] the session's id (nil before the first one)
|
|
94
99
|
def session_id = @host.session_id.call
|
|
95
100
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A single redacted #inspect for the classes that hold the process
|
|
5
|
+
# environment (@env, every API key) or the web token (@token): instead of
|
|
6
|
+
# dumping every instance variable (which would print the keys), it shows the
|
|
7
|
+
# rest with each value's own inspect and prints the others as [redacted]. A
|
|
8
|
+
# failing spec, a debugger or an error message that inspects one of them used
|
|
9
|
+
# to leak every key.
|
|
10
|
+
module RedactedInspect
|
|
11
|
+
# The instance variables never printed.
|
|
12
|
+
REDACTED = %i[@env @token].freeze
|
|
13
|
+
|
|
14
|
+
def inspect
|
|
15
|
+
body = instance_variables.map do |ivar|
|
|
16
|
+
if REDACTED.include?(ivar)
|
|
17
|
+
"#{ivar}=[redacted]"
|
|
18
|
+
else
|
|
19
|
+
"#{ivar}=#{instance_variable_get(ivar).inspect}"
|
|
20
|
+
end
|
|
21
|
+
end.join(", ")
|
|
22
|
+
"#<#{self.class.name} #{body}>"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# pp otherwise ignores inspect.
|
|
26
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -85,7 +85,8 @@ module Samagotchi
|
|
|
85
85
|
# @param first_prompt [String, nil] sent once joined (`chi -p`)
|
|
86
86
|
# @param first_command [String, Array<String>, nil] run before the
|
|
87
87
|
# first prompt, in order (`--model` on a resumed session: "/model X";
|
|
88
|
-
# --llm-context: "/llm-context strategy …"
|
|
88
|
+
# --llm-context: "/llm-context strategy …"; --thinking: "/thinking X
|
|
89
|
+
# --yes"); if one doesn't go
|
|
89
90
|
# through, the launch stops
|
|
90
91
|
# @param no_interrupt [Boolean] post every turn with no_interrupt
|
|
91
92
|
# @param default_input [Boolean] type SAMAGOTCHI_DEFAULT_INPUT into
|
|
@@ -614,8 +615,7 @@ module Samagotchi
|
|
|
614
615
|
end
|
|
615
616
|
|
|
616
617
|
def first_command_queued
|
|
617
|
-
|
|
618
|
-
"(#{flag} queued: applies after the running turn)"
|
|
618
|
+
"(#{first_command_flag} queued: applies after the running turn)"
|
|
619
619
|
end
|
|
620
620
|
|
|
621
621
|
def command_ran(event)
|
|
@@ -1113,7 +1113,7 @@ module Samagotchi
|
|
|
1113
1113
|
nil
|
|
1114
1114
|
end
|
|
1115
1115
|
|
|
1116
|
-
# --model (or --llm-context) on a resumed session: run it in its
|
|
1116
|
+
# --model (or --llm-context, --thinking) on a resumed session: run it in its
|
|
1117
1117
|
# worker before the first prompt goes; its :command_ran decides
|
|
1118
1118
|
# (#first_command_ran), and sends the next one.
|
|
1119
1119
|
# @return [Symbol, nil] :failed when the worker didn't take it
|
|
@@ -1154,7 +1154,14 @@ module Samagotchi
|
|
|
1154
1154
|
end
|
|
1155
1155
|
|
|
1156
1156
|
def first_command_failed
|
|
1157
|
-
|
|
1157
|
+
flag = first_command_flag
|
|
1158
|
+
flag == "--model" ? "could not switch to the --model" : "could not set the #{flag}"
|
|
1159
|
+
end
|
|
1160
|
+
|
|
1161
|
+
# The launch flag the first command came from: "/model X" is --model,
|
|
1162
|
+
# "/llm-context …" --llm-context, "/thinking X --yes" --thinking.
|
|
1163
|
+
def first_command_flag
|
|
1164
|
+
"--#{@first_command_line.to_s[%r{\A/([\w-]+)}, 1]}"
|
|
1158
1165
|
end
|
|
1159
1166
|
|
|
1160
1167
|
# After the join, so it lands below what the session already had. Our
|
|
@@ -28,7 +28,8 @@ module Samagotchi
|
|
|
28
28
|
|
|
29
29
|
# @param kernel [KernelLoop] read lazily: Engine sets its hooks after
|
|
30
30
|
# the kernel is built.
|
|
31
|
-
# @param clock [#call] monotonic seconds, for the approval wait
|
|
31
|
+
# @param clock [#call] monotonic seconds, for the approval wait and the
|
|
32
|
+
# dispatch time (after_tool_call's elapsed_ms)
|
|
32
33
|
def initialize(kernel, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
33
34
|
@kernel = kernel
|
|
34
35
|
@clock = clock
|
|
@@ -71,7 +72,14 @@ module Samagotchi
|
|
|
71
72
|
end
|
|
72
73
|
before = verdict.deny? ? NOT_AN_EDIT : file_before(call)
|
|
73
74
|
index_before = before.equal?(NOT_AN_EDIT) ? nil : memory_index_size(call)
|
|
74
|
-
|
|
75
|
+
elapsed_ms = nil
|
|
76
|
+
if verdict.deny?
|
|
77
|
+
result = denied(call, verdict)
|
|
78
|
+
else
|
|
79
|
+
dispatched_at = @clock.call
|
|
80
|
+
result = dispatch(call)
|
|
81
|
+
elapsed_ms = ((@clock.call - dispatched_at) * 1000).round
|
|
82
|
+
end
|
|
75
83
|
diff = file_change(call, before)
|
|
76
84
|
if diff && refresh_memory_index(call)
|
|
77
85
|
result = with_index_note(result, index_before, call)
|
|
@@ -80,6 +88,9 @@ module Samagotchi
|
|
|
80
88
|
result, images = attach_images(call, result) if result[:images]
|
|
81
89
|
|
|
82
90
|
output = scrub(result[:output].to_s)
|
|
91
|
+
# The tool's own text, for after_tool_call: no "[tool]" lead, no
|
|
92
|
+
# ran-as line, no cap.
|
|
93
|
+
tool_text = output.sub(/\A\[#{Regexp.escape(call[:name].to_s)}\](?:\n| )/, "")
|
|
83
94
|
# The gate may have replaced the call (known-names corrects a near
|
|
84
95
|
# miss): one leading line tells the model what actually ran.
|
|
85
96
|
if (changed = changed_call_line(original, call))
|
|
@@ -104,7 +115,7 @@ module Samagotchi
|
|
|
104
115
|
# After the completed event: a hook's card (check-in, skills) prints
|
|
105
116
|
# under the call's tool row, not before it.
|
|
106
117
|
fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
|
|
107
|
-
status: status || "error" })
|
|
118
|
+
status: status || "error", call: call.dup, elapsed_ms: elapsed_ms, result: tool_text })
|
|
108
119
|
|
|
109
120
|
run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
|
|
110
121
|
run[:images] = images if images&.any?
|
|
@@ -24,6 +24,8 @@ module Samagotchi
|
|
|
24
24
|
NOT_RUN_ON_STOP = "Error: not run, the user stopped the turn"
|
|
25
25
|
# The first words of a command the user's Stop killed.
|
|
26
26
|
STOPPED_BY_USER = "Error: command stopped by the user"
|
|
27
|
+
# The first words of a command killed at the limit (hooks match on it).
|
|
28
|
+
TIMEOUT_PREFIX = "Error: command timed out after"
|
|
27
29
|
TIMEOUT_HINT = "(killed at the limit; for a long command use task_create, then task_wait)"
|
|
28
30
|
# How long the output may stay open once the shell has exited: a
|
|
29
31
|
# `server &` child keeps the pipes, and the read would never end.
|
|
@@ -68,7 +70,7 @@ module Samagotchi
|
|
|
68
70
|
rescue CommandTimedOut => e
|
|
69
71
|
# Keep the "Error:" first line (callers classify on it), say how to run
|
|
70
72
|
# a long command, and return whatever it printed before it was killed.
|
|
71
|
-
stopped_result("
|
|
73
|
+
stopped_result("#{TIMEOUT_PREFIX} #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
|
|
72
74
|
rescue CommandCancelled => e
|
|
73
75
|
stopped_result("#{STOPPED_BY_USER} after #{e.elapsed.round}s (killed; rerun it if still needed)", e)
|
|
74
76
|
rescue StandardError => e
|
|
@@ -189,7 +191,6 @@ module Samagotchi
|
|
|
189
191
|
def self.timeout_seconds
|
|
190
192
|
OutputGuardrails.config_positive_int("execute.timeout_sec", TIMEOUT_SEC)
|
|
191
193
|
end
|
|
192
|
-
private_class_method :timeout_seconds
|
|
193
194
|
|
|
194
195
|
# Resolves the working directory for the child process. An empty/absent
|
|
195
196
|
# cwd defaults to the project root (Dir.pwd), mirroring task_runtime.
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -706,10 +706,12 @@ module Samagotchi
|
|
|
706
706
|
|
|
707
707
|
# POST /api/sessions with continues: <id, prefix or last:id>: the next
|
|
708
708
|
# link of that session's chain (SessionManager.continue_session), in its
|
|
709
|
-
# folder, on its model and with its
|
|
710
|
-
# dir, model, llm_context and
|
|
711
|
-
#
|
|
712
|
-
#
|
|
709
|
+
# folder, on its model and with its own setup (SessionSetup: llm_context,
|
|
710
|
+
# thinking and reasoning_history levels), so dir, model, llm_context and
|
|
711
|
+
# thinking are refused beside it (a create takes no reasoning_history).
|
|
712
|
+
# 409 continued (with next_id, the link that continues it already: the
|
|
713
|
+
# page opens that one), open_children (ids), folder_gone, busy, scratch
|
|
714
|
+
# or owned_by_tui.
|
|
713
715
|
def handle_continue(body, prompt, idle)
|
|
714
716
|
given = body["continues"]
|
|
715
717
|
unless given.is_a?(String) && !given.strip.empty?
|