samagotchi 0.55.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +65 -1
- data/README.md +1 -1
- data/docs/architecture.md +6 -2
- data/docs/cli.md +30 -8
- data/docs/configuration.md +84 -8
- data/docs/hooks.md +2 -2
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/engine.md +14 -0
- data/docs/internals/prompt-caching.md +8 -0
- data/docs/memory.md +1 -1
- data/docs/plugins.md +63 -0
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +2 -0
- data/lib/samagotchi/bootstrap/probe.rb +3 -0
- data/lib/samagotchi/bootstrap_command.rb +2 -0
- data/lib/samagotchi/broadcast_command.rb +2 -0
- data/lib/samagotchi/bundles/core/manifest.yml +2 -1
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +15 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +11 -0
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +320 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/cache_confirm.rb +51 -0
- data/lib/samagotchi/client_id.rb +12 -0
- data/lib/samagotchi/config.rb +7 -3
- data/lib/samagotchi/context_command.rb +2 -0
- data/lib/samagotchi/desktop/macos.rb +3 -0
- data/lib/samagotchi/engine.rb +102 -8
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/gem_update.rb +3 -0
- data/lib/samagotchi/guardrails/shell_lex.rb +8 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -0
- data/lib/samagotchi/host_config.rb +6 -3
- data/lib/samagotchi/host_registry.rb +19 -3
- data/lib/samagotchi/installed_versions.rb +3 -0
- data/lib/samagotchi/kernel_loop.rb +99 -16
- data/lib/samagotchi/llm/chat_loop.rb +122 -71
- data/lib/samagotchi/llm/openai_chat.rb +2 -1
- data/lib/samagotchi/llm/stream_drops.rb +63 -0
- data/lib/samagotchi/llm/turn_settings.rb +9 -5
- data/lib/samagotchi/model_settings.rb +5 -2
- data/lib/samagotchi/plugin/context.rb +5 -0
- data/lib/samagotchi/reasoning_history.rb +132 -0
- data/lib/samagotchi/reasoning_history_command.rb +107 -0
- data/lib/samagotchi/redacted_inspect.rb +28 -0
- data/lib/samagotchi/self_report.rb +21 -0
- data/lib/samagotchi/session.rb +23 -2
- data/lib/samagotchi/session_commands.rb +55 -15
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_setup.rb +7 -5
- data/lib/samagotchi/setting_explained.rb +20 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +23 -5
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/terminal_ui.rb +13 -6
- data/lib/samagotchi/thinking.rb +20 -25
- data/lib/samagotchi/thinking_command.rb +33 -11
- data/lib/samagotchi/tool_runner.rb +14 -3
- data/lib/samagotchi/tools/builtins.rb +6 -2
- data/lib/samagotchi/tools/execute.rb +3 -2
- data/lib/samagotchi/tools/peers.rb +4 -2
- data/lib/samagotchi/tools/task_runtime.rb +2 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +29 -6
- data/lib/samagotchi/web/public/app.js +44 -9
- data/lib/samagotchi/web/public/commands.js +36 -3
- data/lib/samagotchi/web/public/composer.js +5 -1
- data/lib/samagotchi/web/public/index.html +22 -5
- data/lib/samagotchi/web/public/info_bar.js +5 -3
- data/lib/samagotchi/web/public/level_chip.js +174 -0
- data/lib/samagotchi/web/public/model_pick.js +1 -1
- data/lib/samagotchi/web/public/question_card.js +5 -3
- data/lib/samagotchi/web/public/question_cards.js +21 -1
- data/lib/samagotchi/web/public/read_view.js +74 -0
- data/lib/samagotchi/web/public/reasoning_history_chip.js +49 -0
- data/lib/samagotchi/web/public/ref_scan.js +206 -0
- data/lib/samagotchi/web/public/refs.js +19 -10
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +10 -2
- data/lib/samagotchi/web/public/thinking_chip.js +33 -138
- data/lib/samagotchi/web/public/turn_html.js +4 -2
- data/lib/samagotchi/web/public/turn_view.js +5 -3
- data/lib/samagotchi/web/token.rb +3 -0
- data/lib/samagotchi/worker.rb +17 -5
- metadata +13 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mcp
|
|
3
|
-
version: 0.6.
|
|
3
|
+
version: 0.6.2
|
|
4
4
|
scope: system
|
|
5
5
|
description: "Tools from MCP servers (stdio): each server in config.yml bundles: mcp: servers: runs for the session; the model searches their tools with find_mcp_tools and calls one with mcp_call (<server>/<tool>); /mcp lists them"
|
|
6
6
|
trust_level: reviewed
|
|
7
7
|
files: {}
|
|
8
8
|
plugin:
|
|
9
9
|
file: plugin.rb
|
|
10
|
-
sha256: sha256:
|
|
10
|
+
sha256: sha256:8b2c0cf8a48d99b32e320506676d93d8069f9fda4a2ee101112073780a5c4f8a
|
|
11
11
|
# 0.37.0 (published 2026-10-07) is the release whose targets: take acts_as:, args: and label:.
|
|
12
12
|
requires_chi: ">= 0.37.0"
|
|
@@ -276,7 +276,15 @@ class Plugin
|
|
|
276
276
|
# +client+ is the running process's (a restart replaces it; the
|
|
277
277
|
# service's stop closes the current one), +restarts+ how many it had.
|
|
278
278
|
Server = Struct.new(:name, :config, :service, :state, :error, :tools, :listed, :timeout, :cwd, :command, :env,
|
|
279
|
-
:digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, :info, keyword_init: true)
|
|
279
|
+
:digest, :cached_at, :relist, :relisting, :client, :restarts, :tokens, :info, keyword_init: true) do
|
|
280
|
+
# Its state and tools, never the env (values often a token) or the
|
|
281
|
+
# command's args (which may carry a token too): a failing spec or a
|
|
282
|
+
# debugger would print those. `command` and `env` are nil before the
|
|
283
|
+
# server is resolved.
|
|
284
|
+
def inspect = "#<Server #{name} state=#{state} command=#{Array(command).first} env=#{(env || {}).keys.join(",")} tools=#{Array(tools).size}>"
|
|
285
|
+
|
|
286
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
287
|
+
end
|
|
280
288
|
|
|
281
289
|
def initialize(settings = {})
|
|
282
290
|
@settings = settings
|
|
@@ -291,6 +299,12 @@ class Plugin
|
|
|
291
299
|
@left_out_lock = Mutex.new
|
|
292
300
|
end
|
|
293
301
|
|
|
302
|
+
# The servers' names, never @settings (the raw servers config, env values
|
|
303
|
+
# included): a failing spec prints the plugin.
|
|
304
|
+
def inspect = "#<Plugin mcp servers=#{@servers.map(&:name).join(",")}>"
|
|
305
|
+
|
|
306
|
+
def pretty_print(printer) = printer.text(inspect)
|
|
307
|
+
|
|
294
308
|
# Each server's tools come from its cache (tools-<server>.json in the
|
|
295
309
|
# bundle's data dir, keyed by a digest of its config) when there is one:
|
|
296
310
|
# the server then starts on the first call of one of its tools (start:
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: slow-commands
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
scope: system
|
|
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
|
+
trust_level: reviewed
|
|
7
|
+
plugin:
|
|
8
|
+
file: plugin.rb
|
|
9
|
+
sha256: sha256:00a35b8f930d8f99a908b99c5eb3a5e97b1209c63c616037072580d92d3c8639
|
|
10
|
+
# after_tool_call's call:, elapsed_ms: and result: (0.57.0)
|
|
11
|
+
requires_chi: ">= 0.57.0"
|
|
@@ -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
|
|
@@ -58,7 +58,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
|
|
|
58
58
|
|
|
59
59
|
- `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
|
|
60
60
|
- `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
|
|
61
|
-
- `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
61
|
+
- `models:` map of model id or alias → `{profile, vision, sampling, thinking, reasoning_history}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `reasoning_history:` (here or on a host) is `drop|turn|all`, which of its reasoning a chat host's model gets back. Precedence: the session's own level (`/reasoning-history LEVEL`, saved in the session) > `SAMAGOTCHI_REASONING_HISTORY` > `models:` > `hosts.<name>.reasoning_history` > `turn`; `docs/configuration.md` "Reasoning history". `/thinking` and `/reasoning-history` ask before a change that restarts the prompt cache; `--yes` applies it (`chi send` commands apply directly). `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
|
|
62
62
|
- `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
|
|
63
63
|
- `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
|
|
64
64
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: samagotchi-system
|
|
3
|
-
version: 0.
|
|
3
|
+
version: 0.57.0
|
|
4
4
|
scope: system
|
|
5
5
|
description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
|
|
6
6
|
files:
|
|
7
7
|
identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
|
|
8
8
|
self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
|
|
9
|
-
config_modification_protocol.md: sha256:
|
|
10
|
-
memory_guide.md: sha256:
|
|
9
|
+
config_modification_protocol.md: sha256:d908921b5988244fb2a03fe04e693d0e7a1dfadf8cd77704e9232826db7eb2d7
|
|
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
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The confirm step before a session setting change that restarts the
|
|
5
|
+
# prompt cache (/thinking, /reasoning-history): such a change, sent
|
|
6
|
+
# without --yes from a person at a web tab or a chi TUI, changes nothing
|
|
7
|
+
# and replies with what it costs and the line that applies it. Two steps,
|
|
8
|
+
# no stored state: the reply carries a Confirm (SessionCommands::Result,
|
|
9
|
+
# the worker's command_ran), the TUI prefills its line, the web draws an
|
|
10
|
+
# Apply button that sends it.
|
|
11
|
+
module CacheConfirm
|
|
12
|
+
YES_FLAGS = %w[--yes -y].freeze
|
|
13
|
+
# The costs worth asking about (Thinking.cache_cost,
|
|
14
|
+
# ReasoningHistory.cache_cost); :tail and nil are not.
|
|
15
|
+
COSTLY = %i[full provider history].freeze
|
|
16
|
+
|
|
17
|
+
# The line that applies a change, and the question the reply ends with.
|
|
18
|
+
Confirm = Data.define(:line, :text) do
|
|
19
|
+
# The plain form the worker's command_ran carries.
|
|
20
|
+
# @return [Hash] symbol keys
|
|
21
|
+
def to_h = { line: line, text: text }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# A command's argument words with --yes / -y (anywhere) taken out.
|
|
27
|
+
# @param args [String] what follows the command name
|
|
28
|
+
# @return [Array(String, Boolean)] the rest, stripped and downcased, and
|
|
29
|
+
# whether --yes was there
|
|
30
|
+
def split(args)
|
|
31
|
+
words = args.to_s.strip.downcase.split
|
|
32
|
+
yes = words.any? { |word| YES_FLAGS.include?(word) }
|
|
33
|
+
[(words - YES_FLAGS).join(" "), yes]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether a change must ask first: the effective level changes, the
|
|
37
|
+
# cost is one of COSTLY, and the session has a model reply (a cache to
|
|
38
|
+
# restart at all).
|
|
39
|
+
def needed?(cost:, history:, changed:)
|
|
40
|
+
changed && history && COSTLY.include?(cost)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The Confirm for +name+ set to +word+: the line with --yes, and the
|
|
44
|
+
# question naming it.
|
|
45
|
+
# @return [Confirm]
|
|
46
|
+
def confirm(name, word)
|
|
47
|
+
line = "#{name} #{word} --yes"
|
|
48
|
+
Confirm.new(line: line, text: "Nothing changed yet: send `#{line}` to apply.")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -39,8 +39,20 @@ module Samagotchi
|
|
|
39
39
|
HUMAN_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
40
40
|
HUMAN_IDS = [CLI_SEND].freeze
|
|
41
41
|
|
|
42
|
+
# A person at a screen that shows a command's reply and can answer it
|
|
43
|
+
# (#interactive?): a web tab, an attached chi TUI.
|
|
44
|
+
INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
45
|
+
|
|
42
46
|
module_function
|
|
43
47
|
|
|
48
|
+
# Whether +client_id+ is a web tab or a chi TUI, which see a command's
|
|
49
|
+
# reply and can send the line it asks for (CacheConfirm); chi send, a
|
|
50
|
+
# delegate, a plugin, chi itself and nil are not.
|
|
51
|
+
def interactive?(client_id)
|
|
52
|
+
id = client_id.to_s
|
|
53
|
+
INTERACTIVE_PREFIXES.any? { |prefix| id.start_with?(prefix) }
|
|
54
|
+
end
|
|
55
|
+
|
|
44
56
|
# Whether +client_id+ (a turn's or a line's origin) is a human's input.
|
|
45
57
|
def human?(client_id)
|
|
46
58
|
return true if client_id.nil?
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -187,6 +187,10 @@ module Samagotchi
|
|
|
187
187
|
# is bin/chi's own --thinking; models: and hosts: entries rank below
|
|
188
188
|
# the flag and the env, above config.yml's value.
|
|
189
189
|
Entry.new(key: "thinking.level", yaml_path: %w[thinking level], type: :string, default: "default", expose: %i[env config]),
|
|
190
|
+
# Which of its reasoning a chat host's model gets back (ReasoningHistory):
|
|
191
|
+
# the environment only, for A/B runs; models: and hosts: entries are
|
|
192
|
+
# the config surface.
|
|
193
|
+
Entry.new(key: "reasoning_history", yaml_path: %w[reasoning_history], type: :string, default: nil, expose: %i[env]),
|
|
190
194
|
|
|
191
195
|
Entry.new(key: "default.max_tokens", yaml_path: %w[default max_tokens], type: :integer, default: nil, expose: %i[env config cli]),
|
|
192
196
|
Entry.new(key: "max_tool_output_chars", yaml_path: %w[max_tool_output_chars], type: :integer, default: 10_000, expose: %i[env config cli]),
|
|
@@ -269,9 +273,9 @@ module Samagotchi
|
|
|
269
273
|
# Maps of named entries and the keys an entry may hold
|
|
270
274
|
# (ConfigFile.hosts_config, ConfigFile.model_settings).
|
|
271
275
|
MAP_ENTRY_KEYS = {
|
|
272
|
-
"hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking
|
|
273
|
-
remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
|
|
274
|
-
"models" => %w[profile vision sampling thinking window_tokens llm_context_strategy llm_context_apply
|
|
276
|
+
"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
|
|
275
279
|
llm_context_budget_tokens].freeze
|
|
276
280
|
}.freeze
|
|
277
281
|
# The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
|
|
@@ -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
|
|