samagotchi 0.18.1 → 0.20.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 +167 -1
- data/README.md +4 -3
- data/docs/architecture.md +21 -2
- data/docs/cli.md +48 -11
- data/docs/configuration.md +12 -5
- data/docs/guardrails.md +92 -14
- data/docs/memory.md +4 -0
- data/docs/plugins.md +5 -0
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
- data/lib/samagotchi/bootstrap_command.rb +26 -5
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +24 -5
- data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
- data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
- data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
- data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
- data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
- data/lib/samagotchi/commands/registry.rb +4 -3
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_usage.rb +3 -3
- data/lib/samagotchi/engine.rb +25 -5
- data/lib/samagotchi/guardrail_wiring.rb +10 -3
- data/lib/samagotchi/guardrails/approval.rb +14 -7
- data/lib/samagotchi/guardrails/approvals.rb +69 -15
- data/lib/samagotchi/guardrails/context.rb +16 -9
- data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
- data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
- data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
- data/lib/samagotchi/guardrails/rules.rb +69 -5
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
- data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
- data/lib/samagotchi/guardrails/targets.rb +65 -4
- data/lib/samagotchi/guardrails.rb +2 -0
- data/lib/samagotchi/hooks/registry.rb +6 -2
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_recap.rb +7 -2
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/llm/usage.rb +1 -1
- data/lib/samagotchi/memory_bundle/builder.rb +25 -6
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +79 -13
- data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
- data/lib/samagotchi/memory_bundle/status.rb +17 -5
- data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
- data/lib/samagotchi/memory_paths.rb +6 -0
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/self_report.rb +38 -2
- data/lib/samagotchi/session.rb +35 -6
- data/lib/samagotchi/session_commands.rb +13 -2
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +260 -32
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/token_usage.rb +57 -41
- data/lib/samagotchi/tool_runner.rb +21 -2
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/tools/execute.rb +9 -8
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +83 -11
- data/lib/samagotchi/web/public/app.js +135 -43
- data/lib/samagotchi/web/public/card.js +34 -2
- data/lib/samagotchi/web/public/ctx.js +59 -0
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +15 -2
- data/lib/samagotchi/web/public/index.html +37 -2
- data/lib/samagotchi/web/public/model_pick.js +6 -2
- data/lib/samagotchi/web/public/model_picker.js +2 -1
- data/lib/samagotchi/web/public/question_card.js +14 -1
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +108 -4
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +11 -2
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- metadata +7 -1
|
@@ -11,6 +11,7 @@ require_relative "image_input"
|
|
|
11
11
|
require_relative "line_reader"
|
|
12
12
|
require_relative "question_prompt"
|
|
13
13
|
require_relative "reline_seam"
|
|
14
|
+
require_relative "version_lines"
|
|
14
15
|
require_relative "../bridge/turn_accumulator"
|
|
15
16
|
require_relative "../bridge_client"
|
|
16
17
|
require_relative "../log"
|
|
@@ -92,8 +93,10 @@ module Samagotchi
|
|
|
92
93
|
default_input: false, wait_at_eof: false, parent_answers: false,
|
|
93
94
|
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
94
95
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
|
|
95
|
-
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) }
|
|
96
|
+
archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
|
|
97
|
+
installed_version: -> { InstalledVersions.new.newest })
|
|
96
98
|
@client = client
|
|
99
|
+
@installed_version = installed_version
|
|
97
100
|
@delete_session = delete_session
|
|
98
101
|
@archive_session = archive_session
|
|
99
102
|
@screen = screen
|
|
@@ -108,6 +111,8 @@ module Samagotchi
|
|
|
108
111
|
@running = false
|
|
109
112
|
@joined_mid_turn = false
|
|
110
113
|
@attached = false
|
|
114
|
+
# The stream moved to a new worker; its snapshot resyncs (#switch_worker).
|
|
115
|
+
@worker_changed = false
|
|
111
116
|
@early_lines = []
|
|
112
117
|
@question = nil
|
|
113
118
|
@answered_ids = Set.new
|
|
@@ -158,7 +163,12 @@ module Samagotchi
|
|
|
158
163
|
RelineSeam.interrupt_handler = method(:note_interrupted_line)
|
|
159
164
|
# Tagged, so the worker doesn't count this stream as another UI
|
|
160
165
|
# when this UI asks it to exit.
|
|
161
|
-
|
|
166
|
+
# A new worker (a restart) is followed, not lost: its sidecar is
|
|
167
|
+
# looked for whenever the stream drops.
|
|
168
|
+
stream = @client.follow(client_id: @client_id, rediscover: method(:live_worker),
|
|
169
|
+
reconnect_delays: BridgeClient::EventStream::REDISCOVER_DELAYS) do |event|
|
|
170
|
+
queue << [:event, event]
|
|
171
|
+
end
|
|
162
172
|
@reader = LineReader.new(queue, prompt: method(:prompt_text), read: input || method(:read_input_line),
|
|
163
173
|
prefill: default_input_text).start
|
|
164
174
|
loop do
|
|
@@ -280,6 +290,7 @@ module Samagotchi
|
|
|
280
290
|
when :generation_completed
|
|
281
291
|
@status.take_event(event)
|
|
282
292
|
@renderer.call(event)
|
|
293
|
+
when :worker_changed then switch_worker(event)
|
|
283
294
|
when :stream_closed
|
|
284
295
|
@view.finish_thinking_spinner
|
|
285
296
|
@screen.commit("Lost the session's worker (#{event[:reason]}). " \
|
|
@@ -292,6 +303,32 @@ module Samagotchi
|
|
|
292
303
|
|
|
293
304
|
private
|
|
294
305
|
|
|
306
|
+
# The newest chi installed, looked at once, at the join.
|
|
307
|
+
def installed_version
|
|
308
|
+
@installed_version.call
|
|
309
|
+
rescue StandardError
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The session's live worker for the stream to follow (EventStream's
|
|
314
|
+
# rediscover): nil while there is none yet, :gone once the session was
|
|
315
|
+
# stopped (no worker is coming).
|
|
316
|
+
def live_worker
|
|
317
|
+
id = @client.session_id
|
|
318
|
+
return :gone if Session.stopped_marker?(id)
|
|
319
|
+
|
|
320
|
+
BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The stream moved to a new worker (a restart): requests go there too.
|
|
324
|
+
def switch_worker(event)
|
|
325
|
+
@client = BridgeClient.new(session_id: @client.session_id, port: event[:port], host: @client.host)
|
|
326
|
+
@worker_changed = true
|
|
327
|
+
@view.finish_thinking_spinner
|
|
328
|
+
Log.info(:attached, "worker_changed", port: event[:port])
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
|
|
295
332
|
def next_item(queue)
|
|
296
333
|
queue.pop
|
|
297
334
|
rescue Interrupt
|
|
@@ -884,8 +921,13 @@ module Samagotchi
|
|
|
884
921
|
|
|
885
922
|
def render_snapshot(snapshot, reset:)
|
|
886
923
|
@view.finish_thinking_spinner
|
|
924
|
+
# A new worker's first snapshot is the session as it now stands.
|
|
925
|
+
moved = @worker_changed
|
|
926
|
+
reset ||= moved
|
|
927
|
+
@worker_changed = false
|
|
887
928
|
if @attached
|
|
888
|
-
@screen.commit("(resynced with the session)") if reset
|
|
929
|
+
@screen.commit("(resynced with the session)") if reset && !moved
|
|
930
|
+
@screen.commit(paint(VersionLines.restarted(snapshot[:chi_version]), 2)) if moved
|
|
889
931
|
else
|
|
890
932
|
@attached = true
|
|
891
933
|
# The recap first: what the session was about, then where it stopped.
|
|
@@ -894,6 +936,9 @@ module Samagotchi
|
|
|
894
936
|
render_join_header(Array(snapshot[:messages]))
|
|
895
937
|
@screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
|
|
896
938
|
@screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
|
|
939
|
+
version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
|
|
940
|
+
session_id: @client.session_id)
|
|
941
|
+
@screen.commit(paint(version_line, 2)) if version_line
|
|
897
942
|
end
|
|
898
943
|
if snapshot[:commands]
|
|
899
944
|
@command_registry = Commands::Registry.from_listing(snapshot[:commands], base: SessionCommands.builtin_registry)
|
|
@@ -221,6 +221,26 @@ module Samagotchi
|
|
|
221
221
|
duration_ms.nil? ? "" : " · #{format_elapsed_duration(duration_ms)}"
|
|
222
222
|
end
|
|
223
223
|
|
|
224
|
+
# "87 tok/s", "1.9k tok/s"; "~64 tok/s" for an estimate; "" without a
|
|
225
|
+
# speed. The web says the same (ctx.js speedText;
|
|
226
|
+
# spec/shared/labels_matrix.json).
|
|
227
|
+
def speed_text(tps, source)
|
|
228
|
+
value = tps.to_f
|
|
229
|
+
return "" unless value.positive?
|
|
230
|
+
|
|
231
|
+
number = value >= 1000 ? "#{format('%.1f', (value / 100).round / 10.0)}k" : value.round.to_s
|
|
232
|
+
"#{'~' if source.to_s == 'estimate'}#{number} tok/s"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "" for
|
|
236
|
+
# none. As ctx.js costText.
|
|
237
|
+
def cost_text(cost)
|
|
238
|
+
value = cost.to_f
|
|
239
|
+
return "" unless value.positive?
|
|
240
|
+
|
|
241
|
+
value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)
|
|
242
|
+
end
|
|
243
|
+
|
|
224
244
|
private
|
|
225
245
|
|
|
226
246
|
# The recap shown on return: dim, one "recap>" block, noting how many
|
|
@@ -343,7 +363,11 @@ module Samagotchi
|
|
|
343
363
|
end
|
|
344
364
|
lines << "iterations: #{snapshot[:iterations_total]}"
|
|
345
365
|
# Summed over every request: each prompt is sent in full again.
|
|
346
|
-
lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})"
|
|
366
|
+
lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
|
|
367
|
+
"#{token_breakdown_text(tokens)}"
|
|
368
|
+
speed = stats_speed_text(tokens)
|
|
369
|
+
lines << "speed: #{speed}" if speed
|
|
370
|
+
lines << "cost: #{cost_text(tokens[:cost_sum])} (this session only)" if tokens[:cost_sum].to_f.positive?
|
|
347
371
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
348
372
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
349
373
|
lines << "retries: #{snapshot[:retries]}"
|
|
@@ -362,6 +386,33 @@ module Samagotchi
|
|
|
362
386
|
lines.join("\n")
|
|
363
387
|
end
|
|
364
388
|
|
|
389
|
+
# ", cached 4864 (93%), reasoning 212" for the tokens line; the counts a
|
|
390
|
+
# server didn't report are left out.
|
|
391
|
+
def token_breakdown_text(tokens)
|
|
392
|
+
cached = tokens[:cached_sum].to_i
|
|
393
|
+
reasoning = tokens[:reasoning_sum].to_i
|
|
394
|
+
prompt = tokens[:prompt_sum].to_i
|
|
395
|
+
text = +""
|
|
396
|
+
text << ", cached #{cached} (#{(cached * 100.0 / prompt).round}%)" if cached.positive? && prompt.positive?
|
|
397
|
+
text << ", reasoning #{reasoning}" if reasoning.positive?
|
|
398
|
+
text
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# "87 tok/s out, 1.9k tok/s prompt (last, server), avg 81 tok/s", or nil
|
|
402
|
+
# before a generation had a speed. The prompt (prefill) speed only when
|
|
403
|
+
# the server reported it.
|
|
404
|
+
def stats_speed_text(tokens)
|
|
405
|
+
last = tokens[:last_decode_tps]
|
|
406
|
+
return nil unless last
|
|
407
|
+
|
|
408
|
+
source = tokens[:tps_source].to_s
|
|
409
|
+
text = +"#{speed_text(last, source)} out"
|
|
410
|
+
text << ", #{speed_text(tokens[:last_prefill_tps], 'server')} prompt" if tokens[:last_prefill_tps]
|
|
411
|
+
text << " (last, #{source})"
|
|
412
|
+
text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
|
|
413
|
+
text
|
|
414
|
+
end
|
|
415
|
+
|
|
365
416
|
def format_elapsed_duration(duration_ms)
|
|
366
417
|
duration_ms = duration_ms.to_f
|
|
367
418
|
return "" if duration_ms.negative?
|
|
@@ -9,20 +9,27 @@ module Samagotchi
|
|
|
9
9
|
# stays as typed; a token that isn't an image (a source file, a missing
|
|
10
10
|
# path, an email address) is just text.
|
|
11
11
|
#
|
|
12
|
-
# @shot.png @~/Desktop/a.jpg @"my shot.png" @'my shot.png'
|
|
12
|
+
# @shot.png @~/Desktop/a.jpg @"my shot.png" @'my shot.png' @my\ shot.png
|
|
13
|
+
#
|
|
14
|
+
# A bare path takes backslash escapes the way a shell does (what a
|
|
15
|
+
# Finder drag into the terminal types): `\ ` is a space, `\(` a `(`.
|
|
13
16
|
module ImageInput
|
|
14
|
-
# An @ at the start or after whitespace, then a quoted or bare path
|
|
15
|
-
|
|
17
|
+
# An @ at the start or after whitespace, then a quoted or bare path
|
|
18
|
+
# (a bare one runs on through backslash-escaped whitespace).
|
|
19
|
+
TOKEN_RE = /(?:\A|(?<=\s))@(?:"([^"]+)"|'([^']+)'|((?:\\\s|\S)+))/
|
|
16
20
|
|
|
17
21
|
# @return [Array<Hash>] {path:} per image, in order, each path once
|
|
18
22
|
def self.extract(text, cwd: Dir.pwd)
|
|
19
23
|
text.to_s.scan(TOKEN_RE).filter_map do |quoted_double, quoted_single, bare|
|
|
20
|
-
raw = quoted_double || quoted_single || bare.sub(/[,.;:!?)\]]+\z/, "")
|
|
24
|
+
raw = quoted_double || quoted_single || unescape(bare.sub(/(?<!\\)[,.;:!?)\]]+\z/, ""))
|
|
21
25
|
path = expand(raw, cwd)
|
|
22
26
|
path if path && ImageStore.image_file?(path)
|
|
23
27
|
end.uniq.map { |path| { path: path } }
|
|
24
28
|
end
|
|
25
29
|
|
|
30
|
+
def self.unescape(bare) = bare.gsub(/\\(.)/m, '\\1')
|
|
31
|
+
private_class_method :unescape
|
|
32
|
+
|
|
26
33
|
def self.expand(raw, cwd)
|
|
27
34
|
return nil if raw.empty?
|
|
28
35
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../installed_versions"
|
|
4
|
+
require_relative "../version"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class TerminalUI
|
|
8
|
+
# The line an attaching terminal shows when chi versions differ: the
|
|
9
|
+
# session's worker (its snapshot's chi_version), this terminal (VERSION)
|
|
10
|
+
# and the newest installed. Neither process can reload itself: the worker
|
|
11
|
+
# moves with `chi sessions restart`, the terminal by attaching again.
|
|
12
|
+
module VersionLines
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param worker [String, nil] the worker's chi_version (nil: a worker
|
|
16
|
+
# from before it said so; nothing is said then)
|
|
17
|
+
# @param installed [String, nil] InstalledVersions#newest
|
|
18
|
+
# @return [String, nil] one "chi> ..." line, or nil when all is current
|
|
19
|
+
def at_attach(worker:, installed:, session_id:, terminal: VERSION)
|
|
20
|
+
return nil if worker.to_s.empty?
|
|
21
|
+
|
|
22
|
+
newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
|
|
23
|
+
.max_by { |v| Gem::Version.new(v) }
|
|
24
|
+
worker_old = InstalledVersions.newer?(newest, worker)
|
|
25
|
+
terminal_old = InstalledVersions.newer?(newest, terminal)
|
|
26
|
+
return nil unless worker_old || terminal_old
|
|
27
|
+
|
|
28
|
+
id = session_id.to_s[0, 8]
|
|
29
|
+
restart = "chi sessions restart #{id}"
|
|
30
|
+
attach = "/detach, then chi --attach #{id}"
|
|
31
|
+
if worker_old && terminal_old
|
|
32
|
+
"chi> chi #{newest} is installed; this session's worker runs #{worker} and this terminal #{terminal}. " \
|
|
33
|
+
"#{restart} moves the worker; #{attach} for the terminal."
|
|
34
|
+
elsif worker_old
|
|
35
|
+
"chi> chi #{newest} is installed; this session's worker runs #{worker}. #{restart} moves it there " \
|
|
36
|
+
"(this terminal follows)."
|
|
37
|
+
else
|
|
38
|
+
"chi> chi #{newest} is installed; this terminal runs #{terminal}. #{attach} to use it."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# After the stream moved to a new worker (a restart).
|
|
43
|
+
def restarted(worker)
|
|
44
|
+
worker.to_s.empty? ? "chi> the session's worker restarted" : "chi> the session's worker restarted on chi #{worker}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -1,61 +1,58 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Samagotchi
|
|
4
|
-
# TokenUsage
|
|
5
|
-
#
|
|
4
|
+
# TokenUsage is the server-reported token accounting of one model API stream
|
|
5
|
+
# payload, read transport-agnostically.
|
|
6
6
|
#
|
|
7
7
|
# llama.cpp's /completion stream emits per-chunk `timings.prompt_n` /
|
|
8
8
|
# `timings.predicted_n` (cumulative) and a final top-level
|
|
9
|
-
# `tokens_evaluated` / `tokens_predicted`.
|
|
10
|
-
#
|
|
9
|
+
# `tokens_evaluated` / `tokens_predicted`. OpenAI-compatible servers emit a
|
|
10
|
+
# `usage` object with `prompt_tokens` / `completion_tokens`.
|
|
11
11
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
|
|
12
|
+
# The last chunk can say more: llama.cpp adds a `timings` block with exact
|
|
13
|
+
# speeds and `cache_n` (its native `tokens_cached` counts the new tokens too,
|
|
14
|
+
# so it is not read); OpenRouter adds `usage.cost` (USD) and the
|
|
15
|
+
# `prompt_tokens_details.cached_tokens` / `completion_tokens_details.reasoning_tokens`
|
|
16
|
+
# breakdowns. Those fields are nil when a payload doesn't carry them.
|
|
17
|
+
#
|
|
18
|
+
# from_payload returns nil when no server token counts are present, so a
|
|
19
|
+
# caller can fall back to a chars/4 heuristic. The reported values are
|
|
20
|
+
# cumulative per request, so callers should take the running MAX across
|
|
21
|
+
# chunks (see SessionMetrics).
|
|
22
|
+
class TokenUsage < Data.define(:prompt_tokens, :completion_tokens, :cached_tokens, :reasoning_tokens, :cost,
|
|
23
|
+
:predicted_per_second, :prompt_per_second, :predicted_ms, :source)
|
|
16
24
|
CHARS_PER_TOKEN = 4.0
|
|
17
25
|
|
|
18
26
|
class << self
|
|
19
27
|
# @param payload [Hash, nil] the raw streamed chunk payload
|
|
20
|
-
# @return [
|
|
28
|
+
# @return [TokenUsage, nil]
|
|
21
29
|
def from_payload(payload)
|
|
22
30
|
payload_hash = payload.is_a?(Hash) ? payload : {}
|
|
23
|
-
usage = payload_hash
|
|
24
|
-
|
|
31
|
+
usage = hash_at(payload_hash, :usage)
|
|
32
|
+
timings = hash_at(payload_hash, :timings)
|
|
25
33
|
|
|
26
34
|
prompt_tokens = first_positive(
|
|
27
|
-
usage
|
|
28
|
-
|
|
29
|
-
payload_hash["prompt_tokens"],
|
|
30
|
-
payload_hash[:prompt_tokens],
|
|
31
|
-
payload_hash["prompt_n"],
|
|
32
|
-
payload_hash[:prompt_n],
|
|
33
|
-
payload_hash["tokens_evaluated"],
|
|
34
|
-
payload_hash[:tokens_evaluated],
|
|
35
|
-
payload_hash.dig("timings", "prompt_n"),
|
|
36
|
-
payload_hash.dig(:timings, :prompt_n)
|
|
35
|
+
*both(usage, :prompt_tokens), *both(payload_hash, :prompt_tokens), *both(payload_hash, :prompt_n),
|
|
36
|
+
*both(payload_hash, :tokens_evaluated), *both(timings, :prompt_n)
|
|
37
37
|
)
|
|
38
|
-
|
|
39
38
|
completion_tokens = first_positive(
|
|
40
|
-
usage
|
|
41
|
-
|
|
42
|
-
payload_hash["completion_tokens"],
|
|
43
|
-
payload_hash[:completion_tokens],
|
|
44
|
-
payload_hash["predicted_n"],
|
|
45
|
-
payload_hash[:predicted_n],
|
|
46
|
-
payload_hash["tokens_predicted"],
|
|
47
|
-
payload_hash[:tokens_predicted],
|
|
48
|
-
payload_hash.dig("timings", "predicted_n"),
|
|
49
|
-
payload_hash.dig(:timings, :predicted_n)
|
|
39
|
+
*both(usage, :completion_tokens), *both(payload_hash, :completion_tokens),
|
|
40
|
+
*both(payload_hash, :predicted_n), *both(payload_hash, :tokens_predicted), *both(timings, :predicted_n)
|
|
50
41
|
)
|
|
51
|
-
|
|
52
42
|
return nil unless prompt_tokens || completion_tokens
|
|
53
43
|
|
|
54
|
-
|
|
44
|
+
new(
|
|
55
45
|
prompt_tokens: prompt_tokens,
|
|
56
46
|
completion_tokens: completion_tokens,
|
|
47
|
+
cached_tokens: first_positive(*both(hash_at(usage, :prompt_tokens_details), :cached_tokens),
|
|
48
|
+
*both(timings, :cache_n)),
|
|
49
|
+
reasoning_tokens: first_positive(*both(hash_at(usage, :completion_tokens_details), :reasoning_tokens)),
|
|
50
|
+
cost: number(*both(usage, :cost)),
|
|
51
|
+
predicted_per_second: positive_number(*both(timings, :predicted_per_second)),
|
|
52
|
+
prompt_per_second: positive_number(*both(timings, :prompt_per_second)),
|
|
53
|
+
predicted_ms: positive_number(*both(timings, :predicted_ms)),
|
|
57
54
|
source: :server
|
|
58
|
-
|
|
55
|
+
)
|
|
59
56
|
end
|
|
60
57
|
|
|
61
58
|
# Estimate token count from raw text length using the chars/4 heuristic.
|
|
@@ -73,16 +70,35 @@ module Samagotchi
|
|
|
73
70
|
# float never raises RangeError; non-numeric values are skipped.
|
|
74
71
|
def first_positive(*values)
|
|
75
72
|
values.each do |value|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
n = Float(value).to_i
|
|
79
|
-
return n if n.positive?
|
|
80
|
-
rescue ArgumentError, TypeError
|
|
81
|
-
next
|
|
73
|
+
n = float(value)&.to_i
|
|
74
|
+
return n if n&.positive?
|
|
82
75
|
end
|
|
83
76
|
|
|
84
77
|
nil
|
|
85
78
|
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def hash_at(hash, key)
|
|
83
|
+
value = hash[key.to_s] || hash[key]
|
|
84
|
+
value.is_a?(Hash) ? value : {}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def both(hash, key) = [hash[key.to_s], hash[key]]
|
|
88
|
+
|
|
89
|
+
def positive_number(*values) = values.filter_map { |value| float(value) }.find(&:positive?)
|
|
90
|
+
|
|
91
|
+
# A cost of 0 is a real answer (a free model), so zero counts here.
|
|
92
|
+
def number(*values) = values.filter_map { |value| float(value) }.find { |n| n >= 0 }
|
|
93
|
+
|
|
94
|
+
def float(value)
|
|
95
|
+
return nil if value.nil?
|
|
96
|
+
|
|
97
|
+
n = Float(value)
|
|
98
|
+
n.finite? ? n : nil
|
|
99
|
+
rescue ArgumentError, TypeError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
86
102
|
end
|
|
87
103
|
end
|
|
88
104
|
end
|
|
@@ -44,6 +44,7 @@ module Samagotchi
|
|
|
44
44
|
def run(call, iteration:, call_index:, call_count:, on_stream_event:, max_tool_output_chars:)
|
|
45
45
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
46
46
|
# The gate runs first, so tool_call_started shows the call that runs.
|
|
47
|
+
original = call.dup
|
|
47
48
|
verdict = evaluate(call, iteration, params)
|
|
48
49
|
call = verdict.call
|
|
49
50
|
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
@@ -73,6 +74,11 @@ module Samagotchi
|
|
|
73
74
|
result, images = attach_images(call, result) if result[:images]
|
|
74
75
|
|
|
75
76
|
output = scrub(result[:output].to_s)
|
|
77
|
+
# The gate may have replaced the call (known-names corrects a near
|
|
78
|
+
# miss): one leading line tells the model what actually ran.
|
|
79
|
+
if (changed = changed_call_line(original, call))
|
|
80
|
+
output = "#{changed}\n#{output}"
|
|
81
|
+
end
|
|
76
82
|
capped = output
|
|
77
83
|
truncated = false
|
|
78
84
|
if max_tool_output_chars && output.length > max_tool_output_chars
|
|
@@ -84,14 +90,16 @@ module Samagotchi
|
|
|
84
90
|
# full output: ok, error, blocked, stopped); a dispatcher that raised
|
|
85
91
|
# left none, an error.
|
|
86
92
|
status = result[:activity].is_a?(Hash) ? result[:activity][:status] : nil
|
|
87
|
-
fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
|
|
88
|
-
status: status || "error" })
|
|
89
93
|
completed = { type: :tool_call_completed, iteration: iteration, call_count: call_count, call_index: call_index,
|
|
90
94
|
tool: call[:name], output: capped, output_truncated: truncated, activity: result[:activity] }
|
|
91
95
|
completed[:images] = images if images&.any?
|
|
92
96
|
completed[:diff] = diff if diff
|
|
93
97
|
completed[:waited_ms] = waited_ms if waited_ms
|
|
94
98
|
emit(on_stream_event, completed)
|
|
99
|
+
# After the completed event: a hook's card (check-in, skills) prints
|
|
100
|
+
# under the call's tool row, not before it.
|
|
101
|
+
fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
|
|
102
|
+
status: status || "error" })
|
|
95
103
|
|
|
96
104
|
run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
|
|
97
105
|
run[:images] = images if images&.any?
|
|
@@ -203,6 +211,17 @@ module Samagotchi
|
|
|
203
211
|
# A plugin tool's label, what the UIs show for its raw name.
|
|
204
212
|
def plugin_label(name) = ToolActivity.plugin_label(name, registry: tools)
|
|
205
213
|
|
|
214
|
+
# The one leading line the model gets when the gate changed the call it
|
|
215
|
+
# asked for: "ran as: <tool> <changed args>", or nil when nothing
|
|
216
|
+
# changed. The args are the changed call's activity params, so the line
|
|
217
|
+
# stays short.
|
|
218
|
+
def changed_call_line(original, call)
|
|
219
|
+
return nil if original == call
|
|
220
|
+
|
|
221
|
+
params = ToolActivity.tool_activity_params(call[:name], call, registry: tools)
|
|
222
|
+
["ran as: #{call[:name]}", params].compact.join(" ")
|
|
223
|
+
end
|
|
224
|
+
|
|
206
225
|
# The Engine sets the kernel's gate (its context, later the approval
|
|
207
226
|
# flow); a bare kernel (specs) gets one that only runs the hooks.
|
|
208
227
|
def gate
|
|
@@ -22,6 +22,7 @@ require_relative "delegate"
|
|
|
22
22
|
require_relative "delegate_result"
|
|
23
23
|
require_relative "ask_user_question"
|
|
24
24
|
require_relative "../guardrails/parent_approvals"
|
|
25
|
+
require_relative "../model_profile"
|
|
25
26
|
|
|
26
27
|
module Samagotchi
|
|
27
28
|
module Tools
|
|
@@ -73,7 +74,8 @@ module Samagotchi
|
|
|
73
74
|
TaskCreate::NAME => lambda do |call, kctx|
|
|
74
75
|
next Execute::NOT_RUN_ON_STOP if cancelled_proc(kctx).call
|
|
75
76
|
|
|
76
|
-
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
|
|
77
|
+
TaskCreate.call(call[:content], cwd: call[:cwd], env: call[:env],
|
|
78
|
+
marker_env: task_marker_env(kctx, call[:env]))
|
|
77
79
|
end,
|
|
78
80
|
TaskWait::NAME => lambda do |call, kctx|
|
|
79
81
|
TaskWait.call(call[:content], timeout: call[:timeout], tail_lines: call[:tail_lines],
|
|
@@ -108,17 +110,31 @@ module Samagotchi
|
|
|
108
110
|
# wrapper. The session's id, "chi" without one. And the model the
|
|
109
111
|
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
110
112
|
# /model), which chi self reports; unset when unknown, so a value
|
|
111
|
-
# inherited from an outer session never passes for this one.
|
|
112
|
-
|
|
113
|
+
# inherited from an outer session never passes for this one. A
|
|
114
|
+
# worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
|
|
115
|
+
# is unset: it was that run's, not the default of a chi run here.
|
|
116
|
+
def parent_env(kctx, env: ENV)
|
|
113
117
|
peers = kctx.peers
|
|
114
118
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
115
119
|
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
116
|
-
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
117
|
-
|
|
120
|
+
vars = { Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
121
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model }
|
|
122
|
+
if env[ModelProfile::MODEL_FROM_CLI_ENV]
|
|
123
|
+
vars[ModelProfile::MODEL_ENV] = nil
|
|
124
|
+
vars[ModelProfile::MODEL_FROM_CLI_ENV] = nil
|
|
125
|
+
end
|
|
126
|
+
vars
|
|
118
127
|
end
|
|
119
128
|
|
|
120
129
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
121
130
|
|
|
131
|
+
# task_create's parent_env, less the unsets of a variable the model
|
|
132
|
+
# set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
|
|
133
|
+
def task_marker_env(kctx, env)
|
|
134
|
+
given = env.is_a?(Hash) ? env.keys.map(&:to_s) : []
|
|
135
|
+
parent_env(kctx).reject { |key, value| value.nil? && given.include?(key) }
|
|
136
|
+
end
|
|
137
|
+
|
|
122
138
|
# Stop flips the turn's controller, seen through the Engine's PeerView
|
|
123
139
|
# (as DelegateWait does); a bare kernel has no peers: never cancelled.
|
|
124
140
|
def cancelled_proc(kctx)
|
|
@@ -199,22 +199,23 @@ module Samagotchi
|
|
|
199
199
|
# thread; the text is safe to read afterwards.
|
|
200
200
|
def finish
|
|
201
201
|
@thread.join(STOP_POLL_INTERVAL_SEC) unless done?
|
|
202
|
-
|
|
202
|
+
close_quietly
|
|
203
203
|
@thread.kill unless @thread.join(STOP_GRACE_SEC)
|
|
204
204
|
end
|
|
205
205
|
|
|
206
206
|
def text
|
|
207
207
|
@buffer.dup.force_encoding(@io.external_encoding || Encoding.default_external)
|
|
208
208
|
end
|
|
209
|
-
end
|
|
210
|
-
private_constant :OutputReader
|
|
211
209
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
210
|
+
private
|
|
211
|
+
|
|
212
|
+
def close_quietly
|
|
213
|
+
@io.close unless @io.closed?
|
|
214
|
+
rescue IOError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
216
217
|
end
|
|
217
|
-
|
|
218
|
+
private_constant :OutputReader
|
|
218
219
|
|
|
219
220
|
def self.terminate_process_tree(pid)
|
|
220
221
|
signal_process(pid, "TERM")
|
data/lib/samagotchi/turn_note.rb
CHANGED
|
@@ -37,7 +37,8 @@ module Samagotchi
|
|
|
37
37
|
# @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
|
|
38
38
|
# @param seconds [Numeric, nil] how long the turn had run
|
|
39
39
|
# @param shown [Boolean] visible text had streamed (an `[interrupted]`
|
|
40
|
-
# model message precedes this note)
|
|
40
|
+
# model message precedes this note): an answer, or only narration
|
|
41
|
+
# before a tool call, so the note says "reply"
|
|
41
42
|
# @param running_tasks [Array<Hash>] {id:, command:} of this session's
|
|
42
43
|
# background tasks still running: a Stop doesn't end them
|
|
43
44
|
# @param stopped_by [Hash, nil] {by:, reason:} of a hook that stopped
|
|
@@ -45,7 +46,7 @@ module Samagotchi
|
|
|
45
46
|
def cancelled(reason, seconds: nil, shown: false, running_tasks: [], stopped_by: nil)
|
|
46
47
|
why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")}#{stopper(stopped_by)})"
|
|
47
48
|
after = seconds ? " after #{seconds.round}s" : ""
|
|
48
|
-
what = shown ? "the
|
|
49
|
+
what = shown ? "the reply above ends where it was cut off." : "no answer had been shown."
|
|
49
50
|
message("the previous turn was cancelled#{why}#{after}; #{what}#{still_running(running_tasks)}")
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -34,8 +34,9 @@ module Samagotchi
|
|
|
34
34
|
--no-gem don't install a newer gem (config update.gem: false)
|
|
35
35
|
--no-bundles leave the shipped bundles alone (config update.bundles: false)
|
|
36
36
|
--no-desktop leave the desktop helper alone (config update.desktop: false)
|
|
37
|
-
Running sessions keep their chi until
|
|
38
|
-
|
|
37
|
+
Running sessions keep their chi until restarted (chi sessions
|
|
38
|
+
restart ID) or until they idle out with nothing attached; a running
|
|
39
|
+
chi web needs a restart (the sessions it starts run the new chi).
|
|
39
40
|
TEXT
|
|
40
41
|
|
|
41
42
|
# Help is a flag here: it doesn't stop the parse (an unknown option
|
|
@@ -238,22 +239,42 @@ module Samagotchi
|
|
|
238
239
|
def live_rows
|
|
239
240
|
rows = []
|
|
240
241
|
stale = LiveVersions.stale_workers(VERSION, state_dir: @state_dir)
|
|
241
|
-
unless stale.empty?
|
|
242
|
-
versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
|
|
243
|
-
ids = stale.map { |w| w.session_id[0, 8] }.join(" ")
|
|
244
|
-
rows << Row.new(component: "workers",
|
|
245
|
-
status: "#{stale.size} live on #{versions}: they move to #{VERSION} at idle exit (30 min) " \
|
|
246
|
-
"or chi sessions stop #{ids}")
|
|
247
|
-
end
|
|
242
|
+
rows << Row.new(component: "workers", status: workers_status(stale)) unless stale.empty?
|
|
248
243
|
host, port = @web
|
|
249
|
-
|
|
250
|
-
|
|
244
|
+
info = LiveVersions.web_info(host, port)
|
|
245
|
+
web = info && info["version"].to_s
|
|
246
|
+
if web && !web.empty? && web != VERSION
|
|
247
|
+
# Since newest_workers, the sessions it starts run the newest chi.
|
|
248
|
+
spreads = !Array(info["features"]).include?("newest_workers")
|
|
251
249
|
rows << Row.new(component: "chi web :#{port}", from: web,
|
|
252
|
-
status: "running old code: new sessions it starts run #{web} too; restart it")
|
|
250
|
+
status: "running old code#{": new sessions it starts run #{web} too" if spreads}; restart it")
|
|
253
251
|
end
|
|
254
252
|
rows
|
|
255
253
|
end
|
|
256
254
|
|
|
255
|
+
# A worker keeps its chi until restarted: `chi sessions restart` for
|
|
256
|
+
# one that can, a stop for an older one (the next prompt starts it on
|
|
257
|
+
# the newest). It idles out on its own only with nothing attached.
|
|
258
|
+
def workers_status(stale)
|
|
259
|
+
versions = stale.map { |w| w.version || "an older chi" }.uniq.join(", ")
|
|
260
|
+
restart, stop = stale.partition(&:restart).map { |ws| ws.map { |w| w.session_id[0, 8] }.join(" ") }
|
|
261
|
+
moves = []
|
|
262
|
+
moves << "chi sessions restart #{restart}" unless restart.empty?
|
|
263
|
+
moves << "chi sessions stop #{stop}" unless stop.empty?
|
|
264
|
+
"#{stale.size} live on #{versions}: they keep it until restarted (#{moves.join("; ")})#{idle_note}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def idle_note
|
|
268
|
+
minutes = begin
|
|
269
|
+
Config.get("session.idle_exit_minutes").to_f
|
|
270
|
+
rescue StandardError
|
|
271
|
+
30.0
|
|
272
|
+
end
|
|
273
|
+
return "; they never idle out (session.idle_exit_minutes: 0)" unless minutes.positive?
|
|
274
|
+
|
|
275
|
+
"; or they idle out after #{minutes.round(1).to_s.delete_suffix(".0")} min with nothing attached"
|
|
276
|
+
end
|
|
277
|
+
|
|
257
278
|
def footers(options)
|
|
258
279
|
return [] if off(options, "bundles")
|
|
259
280
|
|