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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -1
  3. data/README.md +1 -1
  4. data/docs/cli.md +1 -1
  5. data/docs/hooks.md +2 -2
  6. data/docs/memory.md +1 -1
  7. data/docs/plugins.md +63 -0
  8. data/docs/sessions.md +1 -1
  9. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  10. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  11. data/lib/samagotchi/bootstrap_command.rb +2 -0
  12. data/lib/samagotchi/broadcast_command.rb +2 -0
  13. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  14. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  16. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  17. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  20. data/lib/samagotchi/context_command.rb +2 -0
  21. data/lib/samagotchi/desktop/macos.rb +3 -0
  22. data/lib/samagotchi/gem_update.rb +3 -0
  23. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  24. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  25. data/lib/samagotchi/host_registry.rb +15 -0
  26. data/lib/samagotchi/installed_versions.rb +3 -0
  27. data/lib/samagotchi/plugin/context.rb +5 -0
  28. data/lib/samagotchi/redacted_inspect.rb +28 -0
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +12 -5
  30. data/lib/samagotchi/tool_runner.rb +14 -3
  31. data/lib/samagotchi/tools/execute.rb +3 -2
  32. data/lib/samagotchi/version.rb +1 -1
  33. data/lib/samagotchi/web/app.rb +6 -4
  34. data/lib/samagotchi/web/public/app.js +24 -8
  35. data/lib/samagotchi/web/public/index.html +16 -2
  36. data/lib/samagotchi/web/public/question_card.js +5 -3
  37. data/lib/samagotchi/web/public/question_cards.js +21 -1
  38. data/lib/samagotchi/web/public/read_view.js +74 -0
  39. data/lib/samagotchi/web/public/ref_scan.js +206 -0
  40. data/lib/samagotchi/web/public/refs.js +19 -10
  41. data/lib/samagotchi/web/public/turn_html.js +4 -2
  42. data/lib/samagotchi/web/public/turn_view.js +5 -3
  43. data/lib/samagotchi/web/token.rb +3 -0
  44. 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.56.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:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
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 …"); if one doesn't go
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
- flag = @first_command_line.to_s.start_with?("/model") ? "--model" : "--llm-context"
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
- @first_command_line.to_s.start_with?("/model") ? "could not switch to the --model" : "could not set the --llm-context"
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
- result = verdict.deny? ? denied(call, verdict) : dispatch(call)
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("Error: command timed out after #{timeout_sec}s\n#{TIMEOUT_HINT}", e)
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.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.56.0"
4
+ VERSION = "0.57.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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 llm_context and thinking level, so
710
- # dir, model, llm_context and thinking are refused beside it. 409 continued (with next_id, the
711
- # link that continues it already: the page opens that one),
712
- # open_children (ids), folder_gone, busy, scratch or owned_by_tui.
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?