samagotchi 0.56.0 → 0.58.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +1 -1
  4. data/docs/cli.md +1 -1
  5. data/docs/configuration.md +5 -3
  6. data/docs/hooks.md +2 -2
  7. data/docs/memory.md +1 -1
  8. data/docs/plugins.md +63 -0
  9. data/docs/sessions.md +2 -2
  10. data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
  11. data/lib/samagotchi/bootstrap/probe.rb +3 -0
  12. data/lib/samagotchi/bootstrap_command.rb +2 -0
  13. data/lib/samagotchi/broadcast_command.rb +2 -0
  14. data/lib/samagotchi/bundles/core/manifest.yml +2 -1
  15. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
  17. data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
  18. data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  21. data/lib/samagotchi/context_command.rb +2 -0
  22. data/lib/samagotchi/desktop/macos.rb +3 -0
  23. data/lib/samagotchi/engine.rb +2 -2
  24. data/lib/samagotchi/gem_update.rb +3 -0
  25. data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
  26. data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
  27. data/lib/samagotchi/host_registry.rb +15 -0
  28. data/lib/samagotchi/installed_versions.rb +3 -0
  29. data/lib/samagotchi/model_profile.rb +14 -3
  30. data/lib/samagotchi/plugin/context.rb +5 -0
  31. data/lib/samagotchi/redacted_inspect.rb +43 -0
  32. data/lib/samagotchi/session_commands.rb +5 -4
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +13 -8
  34. data/lib/samagotchi/terminal_ui.rb +14 -5
  35. data/lib/samagotchi/tool_runner.rb +14 -3
  36. data/lib/samagotchi/tools/execute.rb +3 -2
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +14 -6
  39. data/lib/samagotchi/web/public/app.js +31 -14
  40. data/lib/samagotchi/web/public/index.html +32 -5
  41. data/lib/samagotchi/web/public/question_card.js +5 -3
  42. data/lib/samagotchi/web/public/question_cards.js +21 -1
  43. data/lib/samagotchi/web/public/read_view.js +74 -0
  44. data/lib/samagotchi/web/public/ref_menu.js +188 -0
  45. data/lib/samagotchi/web/public/ref_scan.js +216 -0
  46. data/lib/samagotchi/web/public/refs.js +283 -54
  47. data/lib/samagotchi/web/public/stage_view.js +21 -0
  48. data/lib/samagotchi/web/public/turn_html.js +4 -2
  49. data/lib/samagotchi/web/public/turn_view.js +5 -3
  50. data/lib/samagotchi/web/token.rb +3 -0
  51. data/lib/samagotchi/worker.rb +2 -2
  52. metadata +7 -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.58.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
 
@@ -266,8 +266,8 @@ module Samagotchi
266
266
  def wire_kernel(kernel, muted_memories)
267
267
  # A given kernel (specs) gets the Engine's store, hooks and tools, as
268
268
  # the one built here does.
269
- @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
270
- tools: @tools)
269
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: @effective_model_name,
270
+ hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
271
271
  @kernel.reminder_store = @reminder_store
272
272
  @kernel.hooks = @hooks
273
273
  @kernel.tools = @tools
@@ -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*["']([^"']+)["']/
@@ -142,13 +142,24 @@ module Samagotchi
142
142
  end
143
143
  end
144
144
 
145
+ # The model the config names as the default, or nil when it names none.
146
+ # It never raises.
147
+ # @return [String, nil]
148
+ def self.default_model_name
149
+ require_relative "config"
150
+ value = Samagotchi::Config.get("default.model")
151
+ return nil unless value
152
+
153
+ stripped = value.to_s.strip
154
+ stripped.empty? ? nil : stripped
155
+ end
156
+
145
157
  def self.required_model_name(model_name = nil)
146
158
  value = model_name.to_s.strip
147
159
  if value.empty?
148
- require_relative "config"
149
- value = Samagotchi::Config.get("default.model").to_s.strip
160
+ value = default_model_name
150
161
  end
151
- raise MissingModel, missing_model_message if value.empty?
162
+ raise MissingModel, missing_model_message if value.to_s.strip.empty?
152
163
 
153
164
  value
154
165
  end
@@ -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,43 @@
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, or a :token
6
+ # key inside a Hash ivar, as Web::App's @lan): instead of dumping every
7
+ # instance variable (which would print the keys), it shows the rest with
8
+ # each value's own inspect and prints the others as [redacted]. A failing
9
+ # spec, a debugger or an error message that inspects one of them used to
10
+ # leak every key.
11
+ module RedactedInspect
12
+ # The instance variables never printed.
13
+ REDACTED = %i[@env @token].freeze
14
+ # The keys of a Hash ivar whose values are never printed; the rest of the
15
+ # Hash is, so Web::App's @lan still shows its :ip.
16
+ REDACTED_KEYS = %i[token].freeze
17
+
18
+ # Stands in for a redacted Hash value: inspects as [redacted], unquoted.
19
+ HIDDEN = Object.new.tap { |hidden| hidden.define_singleton_method(:inspect) { "[redacted]" } }.freeze
20
+
21
+ def inspect
22
+ body = instance_variables.map do |ivar|
23
+ if REDACTED.include?(ivar)
24
+ "#{ivar}=[redacted]"
25
+ else
26
+ "#{ivar}=#{redacted_value(instance_variable_get(ivar)).inspect}"
27
+ end
28
+ end.join(", ")
29
+ "#<#{self.class.name} #{body}>"
30
+ end
31
+
32
+ # pp otherwise ignores inspect.
33
+ def pretty_print(printer) = printer.text(inspect)
34
+
35
+ private
36
+
37
+ def redacted_value(value)
38
+ return value unless value.is_a?(Hash) && REDACTED_KEYS.any? { |key| value.key?(key) }
39
+
40
+ value.to_h { |key, item| [key, REDACTED_KEYS.include?(key) ? HIDDEN : item] }
41
+ end
42
+ end
43
+ end
@@ -188,12 +188,12 @@ module Samagotchi
188
188
  # @return [String, nil]
189
189
  def self.builtin_name(line) = builtin_registry.lookup(line)&.name
190
190
 
191
- # @return [String] the model /model clear goes back to
191
+ # @return [String, nil] the model /model clear goes back to; nil when none
192
192
  attr_reader :default_model
193
193
 
194
- # @param default_model [String] the config default, which /model clear
194
+ # @param default_model [String, nil] the config default, which /model clear
195
195
  # restores and /model names (the Engine may have started elsewhere, e.g.
196
- # on a resumed session's model)
196
+ # on a resumed session's model); nil when the config names no default
197
197
  # @param save [#call] saves a session (a worker passes its state dir)
198
198
  # @param registry [Commands::Registry] the commands #run looks lines up in
199
199
  def initialize(engine:, turn_flow:, default_model:, save: ->(session) { session.save },
@@ -484,7 +484,7 @@ module Samagotchi
484
484
  def model_command(input)
485
485
  suffix = input.delete_prefix(MODEL_COMMAND).strip
486
486
  if suffix.empty?
487
- return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if model_name == @engine.model_ref_for(@default_model)
487
+ return ["runtime model: #{model_name}#{model_note}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false] if @default_model.nil? || model_name == @engine.model_ref_for(@default_model)
488
488
 
489
489
  return ["runtime model: #{model_name}#{model_note("default: #{@default_model}")}#{served_note}#{sampling_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", false]
490
490
  end
@@ -554,6 +554,7 @@ module Samagotchi
554
554
 
555
555
  if ConfigFile::RESERVED_MODEL_ALIASES.include?(arg.downcase)
556
556
  return ["--default cannot be combined with clear/default/none/off", false] if persist_default
557
+ raise ModelProfile::MissingModel, "no default model is configured (set default.model in config.yml, or /model <name> --default)" if @default_model.nil?
557
558
 
558
559
  switch_model(@default_model)
559
560
  return ["runtime model reset to #{model_name}#{model_note}#{thinking_note}#{reasoning_history_note}#{notes_note}", true]
@@ -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
@@ -1449,9 +1456,7 @@ module Samagotchi
1449
1456
 
1450
1457
  # The config's default model, as the worker's /model names it.
1451
1458
  def default_model_name
1452
- @default_model_name ||= ModelProfile.required_model_name(nil)
1453
- rescue StandardError
1454
- nil
1459
+ @default_model_name ||= ModelProfile.default_model_name
1455
1460
  end
1456
1461
 
1457
1462
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)