samagotchi 0.58.0 → 0.60.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 +86 -1
- data/README.md +3 -2
- data/bin/chi +23 -2
- data/docs/architecture.md +6 -4
- data/docs/cli.md +14 -7
- data/docs/configuration.md +96 -31
- data/docs/hooks.md +12 -6
- data/docs/internals/background-tasks.md +7 -0
- data/docs/internals/engine.md +20 -12
- data/docs/internals/gemma4-contract.md +1 -0
- data/docs/internals/prompt-caching.md +4 -3
- data/docs/internals/tool-guardrails.md +2 -1
- data/docs/memory.md +1 -1
- data/docs/plugins.md +75 -6
- data/docs/sessions.md +2 -2
- data/docs/sub-agent.md +2 -2
- data/docs/testing.md +4 -2
- data/lib/samagotchi/bootstrap_command.rb +2 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -0
- data/lib/samagotchi/bridge.rb +6 -2
- data/lib/samagotchi/bundles/agents-md/manifest.yml +11 -0
- data/lib/samagotchi/bundles/agents-md/plugin.rb +198 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +5 -4
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +169 -16
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/config.rb +44 -10
- data/lib/samagotchi/continue_offer.rb +5 -2
- data/lib/samagotchi/engine.rb +89 -15
- data/lib/samagotchi/host_config.rb +3 -1
- data/lib/samagotchi/host_registry.rb +2 -1
- data/lib/samagotchi/iteration_limit.rb +3 -2
- data/lib/samagotchi/kernel_loop.rb +35 -9
- data/lib/samagotchi/llm/backend.rb +6 -1
- data/lib/samagotchi/llm/chat_loop.rb +11 -6
- data/lib/samagotchi/llm/model_result.rb +11 -2
- data/lib/samagotchi/llm/native_backend.rb +3 -2
- data/lib/samagotchi/log_subscriber.rb +2 -1
- data/lib/samagotchi/model_settings.rb +4 -2
- data/lib/samagotchi/plugin/context.rb +16 -3
- data/lib/samagotchi/process_group.rb +76 -3
- data/lib/samagotchi/prompt_files.rb +127 -0
- data/lib/samagotchi/ref.rb +124 -0
- data/lib/samagotchi/send_command.rb +1 -1
- data/lib/samagotchi/session.rb +15 -3
- data/lib/samagotchi/session_commands.rb +15 -3
- data/lib/samagotchi/session_metrics.rb +8 -2
- data/lib/samagotchi/session_setup.rb +11 -5
- data/lib/samagotchi/steer_cut.rb +12 -8
- data/lib/samagotchi/steer_cut_after.rb +69 -0
- data/lib/samagotchi/system_prompt.rb +74 -18
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +18 -9
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +23 -5
- data/lib/samagotchi/terminal_ui.rb +18 -6
- data/lib/samagotchi/tool_activity.rb +18 -1
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tools/execute.rb +318 -70
- data/lib/samagotchi/tools/task_get.rb +2 -0
- data/lib/samagotchi/tools/task_runtime.rb +96 -13
- data/lib/samagotchi/tools/task_wait.rb +23 -5
- data/lib/samagotchi/turn_flow.rb +22 -3
- data/lib/samagotchi/utf8_default.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/favicon.svg +1 -0
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/read_view.js +3 -1
- data/lib/samagotchi/web/public/refs.js +107 -15
- data/lib/samagotchi/web/public/task_stop.js +25 -7
- data/lib/samagotchi/worker.rb +1 -0
- metadata +7 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: slow-commands
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.2.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Remembers commands that ran past the execute limit in a project; the next identical execute is denied once a turn with advice to run it with task_create and task_wait; /slow-commands lists and forgets them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
plugin:
|
|
8
8
|
file: plugin.rb
|
|
9
|
-
sha256: sha256:
|
|
10
|
-
#
|
|
11
|
-
|
|
9
|
+
sha256: sha256:bf2cfccbcadedcd85ea0b53f2d263c8729e14ed8cf310bca4a56affe50181292
|
|
10
|
+
# Execute::HANDED_OVER, read when the plugin loads (0.59.0); after_tool_call's
|
|
11
|
+
# call:, elapsed_ms: and result: (0.57.0)
|
|
12
|
+
requires_chi: ">= 0.59.0"
|
|
@@ -11,13 +11,22 @@
|
|
|
11
11
|
# (relative to the project), and a trailing output filter dropped (a final
|
|
12
12
|
# 2>&1, `| tail …`, `| head …`, `| grep …`: they don't change how long it
|
|
13
13
|
# runs). Arguments stay: `bundle exec rspec` (the suite) is not
|
|
14
|
-
# `bundle exec rspec spec/x_spec.rb`.
|
|
14
|
+
# `bundle exec rspec spec/x_spec.rb`. Wrappers a model adds by habit are
|
|
15
|
+
# dropped at the start of each simple command (after &&, ||, ;, a newline,
|
|
16
|
+
# so after a leading `cd X &&` too): `time [-p]` and `timeout`/`gtimeout`
|
|
17
|
+
# with its options and duration. `X=…` assignments stay, sorted (they can
|
|
18
|
+
# pick the target or the mode: SPEC=…, RAILS_ENV=…, CI=… (jest/vitest
|
|
19
|
+
# watch without it)), except NO_COLOR, FORCE_COLOR and TERM, which never
|
|
20
|
+
# change what runs; a plain
|
|
21
|
+
# `env X=… cmd` is `X=… cmd`, an `env` with -C/-i/-u (or any other option)
|
|
22
|
+
# stays as typed. The entry's `shown` is the last run as typed. Input that ends inside a quote, $(…)
|
|
15
23
|
# or heredoc is never recorded, nor one with a $(…), backticks or a heredoc
|
|
16
24
|
# cat/tee read as data: ShellLex makes those one placeholder, so different
|
|
17
25
|
# commands would share a key.
|
|
18
26
|
#
|
|
19
|
-
# after_tool_call records: timed out
|
|
20
|
-
#
|
|
27
|
+
# after_tool_call records: timed out (killed at the limit), handed over as
|
|
28
|
+
# a task at the limit (Execute::HANDED_OVER), or ran at least warn_ratio of
|
|
29
|
+
# the limit -> slow; ran under half of it with exit 0 -> the entry is dropped
|
|
21
30
|
# (a fast failure says nothing about the suite's length), unless a trailing
|
|
22
31
|
# filter was stripped: `rspec | tail` exits with tail's 0 when rspec failed,
|
|
23
32
|
# and a `| head` can end it early. A stopped or
|
|
@@ -25,15 +34,23 @@
|
|
|
25
34
|
# running it again in the same turn is let through (maybe it got faster),
|
|
26
35
|
# and that run records the new truth. before_turn resets it.
|
|
27
36
|
#
|
|
28
|
-
# Store: <data_dir>/commands.json, a list of {project, command
|
|
29
|
-
# limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
|
|
37
|
+
# Store: <data_dir>/commands.json, a list of {project, command (the key),
|
|
38
|
+
# shown, slow_at, limit_s, elapsed_s, timed_out}, re-read on each lookup (another session's
|
|
30
39
|
# record counts at once), written whole (AtomicFile), entries older than
|
|
31
40
|
# ttl_days dropped on write.
|
|
32
41
|
#
|
|
33
42
|
# The advice names how long a finished task of the same command took: the
|
|
34
43
|
# project's tmp/tasks/*/task.json read as files (never TaskRuntime's
|
|
35
44
|
# list_records, which mkdirs, runs ps and writes), the exit_code file's
|
|
36
|
-
# mtime minus started_at (finished_at is when chi noticed).
|
|
45
|
+
# mtime minus started_at (finished_at is when chi noticed). The exit_code
|
|
46
|
+
# file is the record's exit_code_path (a handed-over execute keeps it in
|
|
47
|
+
# the state dir), else the one next to task.json.
|
|
48
|
+
#
|
|
49
|
+
# A `timeout N` over the execute limit is denied up front (no history
|
|
50
|
+
# needed) when execute.on_timeout is kill: execute stops it first. Under
|
|
51
|
+
# task (the default) it runs on as a task until timeout fires: nothing to
|
|
52
|
+
# say. Exit codes mean what they always do here: 124/137 (timeout's) are
|
|
53
|
+
# no sign of a slow command by themselves.
|
|
37
54
|
#
|
|
38
55
|
# Settings (config.yml, bundles: slow-commands:):
|
|
39
56
|
# mode: deny deny | notify (warn the user, never deny)
|
|
@@ -46,6 +63,7 @@ require "json"
|
|
|
46
63
|
require "pathname"
|
|
47
64
|
require "time"
|
|
48
65
|
require "samagotchi/atomic_file"
|
|
66
|
+
require "samagotchi/config"
|
|
49
67
|
require "samagotchi/guardrails/shell_lex"
|
|
50
68
|
require "samagotchi/tools/execute"
|
|
51
69
|
require "samagotchi/tools/task_runtime"
|
|
@@ -55,10 +73,23 @@ class Plugin
|
|
|
55
73
|
FILE = "commands.json"
|
|
56
74
|
FILTERS = %w[tail head grep].freeze
|
|
57
75
|
TIMED_OUT = /\A#{Regexp.escape(Samagotchi::Tools::Execute::TIMEOUT_PREFIX)} (\d+)s/
|
|
76
|
+
# Still running at the limit, handed over as a task (chi >= 0.59.0).
|
|
77
|
+
HANDED_OVER = /\A#{Regexp.escape(Samagotchi::Tools::Execute::HANDED_OVER)} (\d+)s limit/
|
|
58
78
|
STOPPED = [Samagotchi::Tools::Execute::STOPPED_BY_USER, Samagotchi::Tools::Execute::NOT_RUN_ON_STOP].freeze
|
|
59
79
|
LEX = Samagotchi::Guardrails::ShellLex
|
|
60
80
|
SHORT_CHARS = 60
|
|
61
81
|
WAIT_DEFAULT = 600
|
|
82
|
+
# Where a simple command starts: a wrapper there is dropped from the key.
|
|
83
|
+
STARTS = ["&&", "||", ";", "\n"].freeze
|
|
84
|
+
ASSIGNMENT = /\A[A-Za-z_][A-Za-z0-9_]*=/
|
|
85
|
+
# Assignments that never change what runs: not part of the key.
|
|
86
|
+
NEUTRAL_VARS = %w[NO_COLOR FORCE_COLOR TERM].freeze
|
|
87
|
+
TIMEOUTS = %w[timeout gtimeout].freeze
|
|
88
|
+
# timeout's options: those that take a value, and the flags.
|
|
89
|
+
TIMEOUT_VALUE_OPTIONS = %w[-s -k --signal --kill-after].freeze
|
|
90
|
+
TIMEOUT_FLAGS = %w[--foreground --preserve-status -v --verbose].freeze
|
|
91
|
+
DURATION = /\A(\d+(?:\.\d+)?|\.\d+)([smhd]?)\z/
|
|
92
|
+
UNIT_SECONDS = { "" => 1, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
|
|
62
93
|
|
|
63
94
|
def initialize(settings = {})
|
|
64
95
|
@mode = settings["mode"].to_s == "notify" ? :notify : :deny
|
|
@@ -87,6 +118,7 @@ class Plugin
|
|
|
87
118
|
tokens = LEX.lex(text)
|
|
88
119
|
return nil if tokens.any? { |kind, value| kind == :word && value.include?("\0") }
|
|
89
120
|
|
|
121
|
+
tokens = unwrap(tokens).first
|
|
90
122
|
tokens = strip_filters(tokens)
|
|
91
123
|
return nil if tokens.empty?
|
|
92
124
|
|
|
@@ -96,6 +128,95 @@ class Plugin
|
|
|
96
128
|
words.join(" ")
|
|
97
129
|
end
|
|
98
130
|
|
|
131
|
+
# The tokens without the wrappers at the start of each simple command
|
|
132
|
+
# (time, timeout), assignments sorted and the neutral ones dropped, and
|
|
133
|
+
# the seconds of every timeout dropped. A simple command left with no
|
|
134
|
+
# words (`NO_COLOR=1 && rspec`) goes with the operator after it, and a
|
|
135
|
+
# last one with the operator before it.
|
|
136
|
+
# @return [Array(Array, Array<Float>)]
|
|
137
|
+
def self.unwrap(tokens)
|
|
138
|
+
durations = []
|
|
139
|
+
out = []
|
|
140
|
+
segment = []
|
|
141
|
+
flush = lambda do
|
|
142
|
+
words, seconds = unwrap_command(segment)
|
|
143
|
+
durations.concat(seconds)
|
|
144
|
+
out.concat(words)
|
|
145
|
+
segment = []
|
|
146
|
+
!words.empty?
|
|
147
|
+
end
|
|
148
|
+
tokens.each do |token|
|
|
149
|
+
if token[0] == :op && STARTS.include?(token[1])
|
|
150
|
+
out << token if flush.call
|
|
151
|
+
else
|
|
152
|
+
segment << token
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
out.pop if !flush.call && out.last&.first == :op && STARTS.include?(out.last[1])
|
|
156
|
+
[out, durations]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One simple command's tokens (it may hold a | or & further on: only its
|
|
160
|
+
# start is looked at).
|
|
161
|
+
def self.unwrap_command(tokens)
|
|
162
|
+
rest = tokens.dup
|
|
163
|
+
assignments = []
|
|
164
|
+
durations = []
|
|
165
|
+
loop do
|
|
166
|
+
assignments << rest.shift[1] while word?(rest.first) && rest.first[1].match?(ASSIGNMENT)
|
|
167
|
+
head = word?(rest.first) ? rest.first[1] : nil
|
|
168
|
+
# time -p only: another option (GNU time -v) is a different program.
|
|
169
|
+
if head == "time" && !(word?(rest[1]) && rest[1][1].start_with?("-") && rest[1][1] != "-p")
|
|
170
|
+
rest.shift
|
|
171
|
+
rest.shift if word?(rest.first) && rest.first[1] == "-p"
|
|
172
|
+
elsif head == "env" && word?(rest[1]) && !rest[1][1].start_with?("-")
|
|
173
|
+
rest.shift
|
|
174
|
+
elsif TIMEOUTS.include?(head) && (skipped = timeout_length(rest))
|
|
175
|
+
count, seconds = skipped
|
|
176
|
+
rest.shift(count)
|
|
177
|
+
durations << seconds
|
|
178
|
+
else
|
|
179
|
+
break
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
kept = assignments.reject { |a| NEUTRAL_VARS.include?(a.split("=", 2).first) }.sort
|
|
183
|
+
[kept.map { |a| [:word, a, ""] } + rest, durations]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# [words to drop, seconds] for a `timeout [opts] DUR` at the start of
|
|
187
|
+
# +tokens+, or nil when it can't be read (then it stays in the key).
|
|
188
|
+
def self.timeout_length(tokens)
|
|
189
|
+
i = 1
|
|
190
|
+
while word?(tokens[i]) && tokens[i][1].start_with?("-")
|
|
191
|
+
option = tokens[i][1]
|
|
192
|
+
if TIMEOUT_FLAGS.include?(option) || option.start_with?("--signal=", "--kill-after=")
|
|
193
|
+
i += 1
|
|
194
|
+
elsif TIMEOUT_VALUE_OPTIONS.include?(option)
|
|
195
|
+
i += 2
|
|
196
|
+
else
|
|
197
|
+
return nil
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
seconds = word?(tokens[i]) ? duration_seconds(tokens[i][1]) : nil
|
|
201
|
+
seconds && word?(tokens[i + 1]) ? [i + 1, seconds] : nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def self.duration_seconds(text)
|
|
205
|
+
match = DURATION.match(text)
|
|
206
|
+
match ? Float(match[1]) * UNIT_SECONDS.fetch(match[2]) : nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.word?(token) = token && token[0] == :word
|
|
210
|
+
|
|
211
|
+
# The longest `timeout N` (seconds) at the start of a simple command in
|
|
212
|
+
# +text+, or nil for none (or text that can't be keyed).
|
|
213
|
+
def self.timeout_seconds(text)
|
|
214
|
+
text = text.to_s.strip
|
|
215
|
+
return nil if text.empty? || LEX.unterminated?(text)
|
|
216
|
+
|
|
217
|
+
unwrap(LEX.lex(text)).last.max
|
|
218
|
+
end
|
|
219
|
+
|
|
99
220
|
# Whether the key dropped a trailing output filter (2>&1, | tail …):
|
|
100
221
|
# the exit code is then the filter's, and the run may have been cut short.
|
|
101
222
|
def self.filtered?(text)
|
|
@@ -148,18 +269,44 @@ class Plugin
|
|
|
148
269
|
key = key_for(call, ctx)
|
|
149
270
|
return unless key
|
|
150
271
|
return if @denied[key]
|
|
272
|
+
return if over_limit_timeout(call, verdict, ctx, key)
|
|
151
273
|
|
|
152
274
|
entry = live_entries(ctx).find { |e| e["project"] == key[0] && e["command"] == key[1] }
|
|
153
275
|
return unless entry
|
|
154
276
|
|
|
155
277
|
@denied[key] = true
|
|
156
278
|
if @mode == :notify
|
|
157
|
-
ctx.notify("execute #{short(
|
|
279
|
+
ctx.notify("execute #{short(call[:content])}: #{what(entry)} before", level: :warn)
|
|
158
280
|
return
|
|
159
281
|
end
|
|
160
282
|
|
|
161
283
|
verdict.deny!("slow command", source: SOURCE, advice: advice(entry, ctx, key))
|
|
162
|
-
ctx.notify("execute #{short(
|
|
284
|
+
ctx.notify("execute #{short(call[:content])}: #{what(entry)} before, denied", level: :warn)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# A `timeout N` over the limit while execute kills at it (on_timeout:
|
|
288
|
+
# kill): N never takes effect. Denied once a turn, like a slow entry.
|
|
289
|
+
# @return [Boolean] whether it was handled
|
|
290
|
+
def over_limit_timeout(call, verdict, ctx, key)
|
|
291
|
+
return false unless Samagotchi::Config.get("execute.on_timeout").to_s == "kill"
|
|
292
|
+
|
|
293
|
+
seconds = self.class.timeout_seconds(call[:content])
|
|
294
|
+
limit = Samagotchi::Tools::Execute.timeout_seconds
|
|
295
|
+
return false unless seconds && seconds > limit
|
|
296
|
+
|
|
297
|
+
@denied[key] = true
|
|
298
|
+
wait = [WAIT_DEFAULT, seconds.ceil].max
|
|
299
|
+
line = "execute #{short(call[:content])}: timeout #{duration(seconds.ceil)} is past the #{limit} s limit"
|
|
300
|
+
if @mode == :notify
|
|
301
|
+
ctx.notify(line, level: :warn)
|
|
302
|
+
else
|
|
303
|
+
verdict.deny!("timeout past the execute limit", source: SOURCE,
|
|
304
|
+
advice: "execute stops a command at #{limit} s, so its timeout " \
|
|
305
|
+
"(#{duration(seconds.ceil)}) never takes effect. Start it " \
|
|
306
|
+
"with task_create, then task_wait (timeout #{wait}).")
|
|
307
|
+
ctx.notify("#{line}, denied", level: :warn)
|
|
308
|
+
end
|
|
309
|
+
true
|
|
163
310
|
end
|
|
164
311
|
|
|
165
312
|
def after(event, ctx)
|
|
@@ -174,10 +321,11 @@ class Plugin
|
|
|
174
321
|
return unless key
|
|
175
322
|
|
|
176
323
|
limit = Samagotchi::Tools::Execute.timeout_seconds
|
|
177
|
-
|
|
178
|
-
|
|
324
|
+
shown = call[:content].to_s.strip
|
|
325
|
+
if (timed_out = result[TIMED_OUT, 1] || result[HANDED_OVER, 1])
|
|
326
|
+
remember(ctx, key, shown, limit_s: timed_out.to_i, elapsed_s: timed_out.to_i, timed_out: true)
|
|
179
327
|
elsif elapsed_ms >= @warn_ratio * limit * 1000
|
|
180
|
-
remember(ctx, key, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
|
|
328
|
+
remember(ctx, key, shown, limit_s: limit, elapsed_s: (elapsed_ms / 1000.0).round, timed_out: false)
|
|
181
329
|
elsif elapsed_ms < 0.5 * limit * 1000 && exit_zero?(result) && !self.class.filtered?(call[:content])
|
|
182
330
|
forget_key(ctx, key)
|
|
183
331
|
end
|
|
@@ -218,9 +366,10 @@ class Plugin
|
|
|
218
366
|
Samagotchi::AtomicFile.write(store_path(ctx), "#{JSON.pretty_generate(list.select { |e| live?(e) })}\n")
|
|
219
367
|
end
|
|
220
368
|
|
|
221
|
-
|
|
369
|
+
# +shown+: the command as this run typed it (the list shows it).
|
|
370
|
+
def remember(ctx, key, shown, limit_s:, elapsed_s:, timed_out:)
|
|
222
371
|
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,
|
|
372
|
+
list << { "project" => key[0], "command" => key[1], "shown" => shown, "slow_at" => Time.now.utc.iso8601,
|
|
224
373
|
"limit_s" => limit_s, "elapsed_s" => elapsed_s, "timed_out" => timed_out }
|
|
225
374
|
save(ctx, list)
|
|
226
375
|
end
|
|
@@ -259,7 +408,8 @@ class Plugin
|
|
|
259
408
|
# [started_at, seconds] for a task record of +key+ that ended with exit 0.
|
|
260
409
|
def finished_task(path, ctx, key)
|
|
261
410
|
record = JSON.parse(File.read(path))
|
|
262
|
-
exit_code =
|
|
411
|
+
exit_code = record["exit_code_path"].to_s
|
|
412
|
+
exit_code = File.join(File.dirname(path), Samagotchi::Tools::TaskRuntime::EXIT_CODE_FILENAME) unless File.absolute_path?(exit_code)
|
|
263
413
|
return nil unless File.file?(exit_code) && File.read(exit_code).strip == "0"
|
|
264
414
|
|
|
265
415
|
command = self.class.normalize(record["command"], cwd: record["cwd"], project: key[0], base: ctx.cwd)
|
|
@@ -282,7 +432,7 @@ class Plugin
|
|
|
282
432
|
return "slow-commands: none recorded for #{project}" if mine.empty?
|
|
283
433
|
|
|
284
434
|
lines = mine.each_with_index.map do |e, i|
|
|
285
|
-
"#{i + 1}. #{e
|
|
435
|
+
"#{i + 1}. #{shown(e)}: #{what(e)} (#{e["slow_at"].to_s[0, 10]})"
|
|
286
436
|
end
|
|
287
437
|
"slow-commands in #{project}:\n#{lines.join("\n")}\n/slow-commands forget <n|all> drops an entry"
|
|
288
438
|
when /\Aforget\s+(all|\d+)\z/
|
|
@@ -291,7 +441,7 @@ class Plugin
|
|
|
291
441
|
return "slow-commands: no entry #{which} (/slow-commands lists them)" if drop.empty?
|
|
292
442
|
|
|
293
443
|
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
|
|
444
|
+
"slow-commands: forgot #{drop.size == 1 ? shown(drop.first) : "#{drop.size} commands"}"
|
|
295
445
|
else
|
|
296
446
|
"usage: /slow-commands (list this project's slow commands), /slow-commands forget <n|all>"
|
|
297
447
|
end
|
|
@@ -299,6 +449,9 @@ class Plugin
|
|
|
299
449
|
|
|
300
450
|
# --- text ---
|
|
301
451
|
|
|
452
|
+
# The command as last typed; an entry from 0.1.0 has only its key.
|
|
453
|
+
def shown(entry) = entry["shown"].to_s.empty? ? entry["command"] : entry["shown"]
|
|
454
|
+
|
|
302
455
|
def what(entry)
|
|
303
456
|
entry["timed_out"] ? "ran past #{entry["limit_s"]} s" : "took #{duration(entry["elapsed_s"])} of #{entry["limit_s"]} s"
|
|
304
457
|
end
|
|
@@ -121,7 +121,7 @@ A guardrail rule's `tool:` may be a glob (`tool: "mcp_*"`, verdict `ask`) to cov
|
|
|
121
121
|
1. **Read** the current file via `read` tool (or `ConfigFile.global_path`). If `File.file?` false, start from `{}`.
|
|
122
122
|
2. `YAML.safe_load` (permitted_classes: [], aliases: false). If data nil or not Hash, treat as `{}` or raise with path.
|
|
123
123
|
3. Mutate the intended **nested** key in the raw hash. Preserve all other keys byte-for-byte where possible. Example for default model: `raw_data["default"] ||= {}; raw_data["default"]["model"] = "new-model"`.
|
|
124
|
-
4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `
|
|
124
|
+
4. **Validate** (see below) before writing. Also run `Samagotchi::Config.validate_yaml_sections` — it returns one `config: unknown key '…' (did you mean '…'?)` per key chi doesn't read (every config-exposed `Config::ENTRIES` key is known as written, including the section-less `max_tool_output_chars` and `skip_agents_md`; names under `hosts:`/`models:`/`model_aliases:`/`hooks:`/`bundles:`/`memories:`/`append_system_prompt:` are free-form, host and model entries are checked against `Config::MAP_ENTRY_KEYS`). An empty list means no warning at start.
|
|
125
125
|
5. **Write**: change only the lines you mean to, with the edit tool, so the user's comments and layout stay; `YAML.dump` of the whole hash drops every comment, so rewrite the file only when it has none worth keeping. From Ruby, write atomically with `Samagotchi::AtomicFile.write(path, text)` (a unique temp file renamed over it), never through a fixed `"#{path}.tmp"` that another chi may be writing at the same time.
|
|
126
126
|
6. Nothing else to update: config.yml values are never copied into `ENV`, and every `Config.get` reads the file again when it changed, so the next read (and every worker started after the write) sees the new value with origin `:file`. What a running worker set up at its start (`hosts:`, bundle settings) waits for its restart; guardrail rules and `model_aliases:` are read again live. CLI overrides (`--model`, `--recap-model`, …) win over the file until the process exits; a worker gets its spawner's CLI settings through its env (`Config.cli_env`).
|
|
127
127
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.60.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
|
-
config_modification_protocol.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:16403da5dd97a335e19d9d15bb1ba4390237bf9f4869e35d63c172a8828b5ae0
|
|
10
10
|
memory_guide.md: sha256:d16919387c42d229498f470fc4078245a15faee872480ffbeffe2e27897624e4
|
|
11
11
|
delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -20,6 +20,8 @@ module Samagotchi
|
|
|
20
20
|
# One models: entry, and one field found in them; they need ConfigFile's readers.
|
|
21
21
|
autoload :ModelSettings, File.expand_path("model_settings", __dir__)
|
|
22
22
|
autoload :ModelSetting, File.expand_path("model_settings", __dir__)
|
|
23
|
+
# The per-model/host seconds a cut waits (like Thinking for the level).
|
|
24
|
+
autoload :SteerCutAfter, File.expand_path("steer_cut_after", __dir__)
|
|
23
25
|
|
|
24
26
|
# Unified configuration registry implementing the implicit convention:
|
|
25
27
|
# ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
|
|
@@ -216,11 +218,12 @@ module Samagotchi
|
|
|
216
218
|
Entry.new(key: "retry.empty_answer", yaml_path: %w[retry empty_answer], type: :integer, default: 1, expose: %i[env config]),
|
|
217
219
|
# Seconds a generation must have streamed only thinking before a message
|
|
218
220
|
# for the running turn (the user's, chi send's, a parent agent's) cuts
|
|
219
|
-
# it (Engine#cut_for_steer); 0 = never.
|
|
221
|
+
# it (Engine#cut_for_steer); 0 = never. hosts.<name>.steer_cut_after and
|
|
222
|
+
# models.<key>.steer_cut_after win. No CLI flag: workers get no CLI args.
|
|
220
223
|
Entry.new(key: "steer.cut_after", yaml_path: %w[steer cut_after], type: :integer, default: 20, expose: %i[env config]),
|
|
221
224
|
# A turn's step limit: model ↔ tool rounds before it stops and offers to continue (IterationLimit); integer ≥ 1.
|
|
222
225
|
# --no-interrupt turns get max(1000, this). No CLI flag: --no-interrupt is the flag.
|
|
223
|
-
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default:
|
|
226
|
+
Entry.new(key: "turn.max_iterations", yaml_path: %w[turn max_iterations], type: :integer, default: 300, expose: %i[env config]),
|
|
224
227
|
# Whether a parent agent (chi answer) may answer Continue to a session's step-limit question; false: Stop only.
|
|
225
228
|
# config.yml only, like guardrails.parent_approvals: a parent must not decide it from its own environment.
|
|
226
229
|
Entry.new(key: "turn.parent_continue", yaml_path: %w[turn parent_continue], type: :bool, default: true, expose: %i[config]),
|
|
@@ -242,8 +245,11 @@ module Samagotchi
|
|
|
242
245
|
Entry.new(key: "execute.truncate_at_bytes", yaml_path: %w[execute truncate_at_bytes], type: :integer, default: 65_536, expose: %i[env config cli]),
|
|
243
246
|
Entry.new(key: "execute.preview_bytes", yaml_path: %w[execute preview_bytes], type: :integer, default: 12_288, expose: %i[env config cli]),
|
|
244
247
|
Entry.new(key: "execute.telemetry_threshold_pct", yaml_path: %w[execute telemetry_threshold_pct], type: :integer, default: 80, expose: %i[env config cli]),
|
|
245
|
-
# Seconds one execute command may run before
|
|
248
|
+
# Seconds one execute command may run before execute.on_timeout applies (Tools::Execute::TIMEOUT_SEC).
|
|
246
249
|
Entry.new(key: "execute.timeout_sec", yaml_path: %w[execute timeout_sec], type: :integer, default: 120, expose: %i[env config cli]),
|
|
250
|
+
# What happens to a command still running at that limit (Tools::Execute::ON_TIMEOUT): task hands it over as a
|
|
251
|
+
# background task (task_wait waits for it), kill stops it.
|
|
252
|
+
Entry.new(key: "execute.on_timeout", yaml_path: %w[execute on_timeout], type: :enum, default: "task", expose: %i[env config cli], enum_values: %w[task kill]),
|
|
247
253
|
# Whether execute declares its optional description parameter (a few model-written words, a tool row's title).
|
|
248
254
|
Entry.new(key: "execute.description", yaml_path: %w[execute description], type: :bool, default: true, expose: %i[env config]),
|
|
249
255
|
|
|
@@ -263,19 +269,19 @@ module Samagotchi
|
|
|
263
269
|
# env+config only
|
|
264
270
|
Entry.new(key: "default.input", yaml_path: %w[default input], type: :string, default: nil, expose: %i[env config]),
|
|
265
271
|
Entry.new(key: "history.file", yaml_path: %w[history file], type: :string, default: nil, expose: %i[env config]),
|
|
266
|
-
Entry.new(key: "
|
|
272
|
+
Entry.new(key: "skip_agents_md", yaml_path: %w[skip_agents_md], type: :bool, default: false, expose: %i[env config])
|
|
267
273
|
].freeze
|
|
268
274
|
|
|
269
|
-
# Top-level maps read by their own code, whose entry names and
|
|
270
|
-
# are the user's: ConfigFile.model_aliases / preloaded_memories
|
|
271
|
-
# Hooks::Loader, Engine#read_bundle_settings.
|
|
272
|
-
FREE_FORM_MAPS = %w[model_aliases hooks bundles memories].freeze
|
|
275
|
+
# Top-level maps and lists read by their own code, whose entry names and
|
|
276
|
+
# contents are the user's: ConfigFile.model_aliases / preloaded_memories /
|
|
277
|
+
# appended_prompt_files, Hooks::Loader, Engine#read_bundle_settings.
|
|
278
|
+
FREE_FORM_MAPS = %w[model_aliases hooks bundles memories append_system_prompt].freeze
|
|
273
279
|
# Maps of named entries and the keys an entry may hold
|
|
274
280
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
275
281
|
MAP_ENTRY_KEYS = {
|
|
276
282
|
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
|
|
277
|
-
reasoning_history enabled remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
278
|
-
"models" => %w[profile vision sampling thinking reasoning_history window_tokens llm_context_strategy llm_context_apply
|
|
283
|
+
reasoning_history enabled remote window_tokens steer_cut_after llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
284
|
+
"models" => %w[profile vision sampling thinking reasoning_history window_tokens steer_cut_after llm_context_strategy llm_context_apply
|
|
279
285
|
llm_context_budget_tokens].freeze
|
|
280
286
|
}.freeze
|
|
281
287
|
# The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
|
|
@@ -981,6 +987,34 @@ module Samagotchi
|
|
|
981
987
|
[]
|
|
982
988
|
end
|
|
983
989
|
|
|
990
|
+
# Resolve the `append_system_prompt:` list: files whose text goes into
|
|
991
|
+
# the system prompt after AGENTS.md (SystemPrompt). A YAML list of
|
|
992
|
+
# strings or a single string; ~ is expanded, a relative entry is kept as
|
|
993
|
+
# written (resolved against the cwd the prompt is built in). [] when
|
|
994
|
+
# the key is absent or malformed.
|
|
995
|
+
# @return [Array<String>]
|
|
996
|
+
def appended_prompt_files(env: ENV, path: global_path(env: env))
|
|
997
|
+
data = read_yaml(env: env, path: path)
|
|
998
|
+
raw = data["append_system_prompt"] if data.is_a?(Hash)
|
|
999
|
+
items = case raw
|
|
1000
|
+
when Array then raw
|
|
1001
|
+
when String then [raw]
|
|
1002
|
+
else []
|
|
1003
|
+
end
|
|
1004
|
+
items.select { |v| v.is_a?(String) }.map(&:strip).reject(&:empty?).filter_map { |v| expand_appended_entry(v) }
|
|
1005
|
+
rescue StandardError
|
|
1006
|
+
[]
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
# An append_system_prompt: entry with ~ expanded; nil (skipped, logged)
|
|
1010
|
+
# for a ~user that doesn't exist, so the other entries still load.
|
|
1011
|
+
def expand_appended_entry(entry)
|
|
1012
|
+
entry.start_with?("~") ? File.expand_path(entry) : entry
|
|
1013
|
+
rescue ArgumentError => e
|
|
1014
|
+
Samagotchi::Log.warn(:config, "appended_prompt_file_skipped", entry: entry, error: e.message) if defined?(Samagotchi::Log)
|
|
1015
|
+
nil
|
|
1016
|
+
end
|
|
1017
|
+
|
|
984
1018
|
# Parse a model string that may be qualified as "host_alias:model"
|
|
985
1019
|
# Returns [host_alias_or_nil, bare_model] (ModelRef.split)
|
|
986
1020
|
def parse_host_qualified_model(raw, hosts: nil)
|
|
@@ -111,13 +111,15 @@ module Samagotchi
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
# The continue offer was answered yes: resume the conversation without a
|
|
114
|
-
# user message, with the iteration limit the offer's turn had
|
|
114
|
+
# user message, with the iteration limit the offer's turn had, from the
|
|
115
|
+
# run that turn started (TurnFlow#resume_from: its reasoning stays). A failure
|
|
115
116
|
# keeps the offer, as the REPL does ("continue prompt preserved"), and
|
|
116
117
|
# asks again, one before the turn began too (logged, no :turn_failed).
|
|
117
118
|
# The answer's text is carried into the turn as it begins; a turn that
|
|
118
119
|
# never began leaves it to no other.
|
|
119
120
|
def run_continue_turn(command)
|
|
120
121
|
offer = @turn_flow.offer
|
|
122
|
+
resume_from = @turn_flow.resume_from
|
|
121
123
|
close("answered")
|
|
122
124
|
steer = @continue_steer
|
|
123
125
|
@continue_steer = nil
|
|
@@ -129,7 +131,8 @@ module Samagotchi
|
|
|
129
131
|
Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
|
|
130
132
|
return announce_offer(offer)
|
|
131
133
|
end
|
|
132
|
-
@run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact,
|
|
134
|
+
@run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact, resumed: true,
|
|
135
|
+
resume_from: resume_from,
|
|
133
136
|
max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
|
|
134
137
|
if error
|
|
135
138
|
announce_offer(offer)
|