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.
Files changed (101) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +167 -1
  3. data/README.md +4 -3
  4. data/docs/architecture.md +21 -2
  5. data/docs/cli.md +48 -11
  6. data/docs/configuration.md +12 -5
  7. data/docs/guardrails.md +92 -14
  8. data/docs/memory.md +4 -0
  9. data/docs/plugins.md +5 -0
  10. data/docs/sessions.md +7 -5
  11. data/lib/samagotchi/bootstrap/config_writer.rb +14 -3
  12. data/lib/samagotchi/bootstrap_command.rb +26 -5
  13. data/lib/samagotchi/bridge.rb +25 -9
  14. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  15. data/lib/samagotchi/bridge_client.rb +9 -5
  16. data/lib/samagotchi/bundle_command.rb +24 -5
  17. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/check-in/plugin.rb +14 -5
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +24 -5
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +5 -4
  21. data/lib/samagotchi/bundles/mcp/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/mcp/plugin.rb +18 -4
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/system/memory_guide.md +2 -2
  27. data/lib/samagotchi/commands/registry.rb +4 -3
  28. data/lib/samagotchi/config.rb +3 -0
  29. data/lib/samagotchi/context_usage.rb +3 -3
  30. data/lib/samagotchi/engine.rb +25 -5
  31. data/lib/samagotchi/guardrail_wiring.rb +10 -3
  32. data/lib/samagotchi/guardrails/approval.rb +14 -7
  33. data/lib/samagotchi/guardrails/approvals.rb +69 -15
  34. data/lib/samagotchi/guardrails/context.rb +16 -9
  35. data/lib/samagotchi/guardrails/parent_approvals.rb +10 -4
  36. data/lib/samagotchi/guardrails/read_only_shell.rb +322 -0
  37. data/lib/samagotchi/guardrails/rm_targets.rb +86 -0
  38. data/lib/samagotchi/guardrails/rules.rb +69 -5
  39. data/lib/samagotchi/guardrails/shell_git_dirs.rb +5 -137
  40. data/lib/samagotchi/guardrails/shell_lex.rb +164 -0
  41. data/lib/samagotchi/guardrails/shell_paths.rb +145 -0
  42. data/lib/samagotchi/guardrails/targets.rb +65 -4
  43. data/lib/samagotchi/guardrails.rb +2 -0
  44. data/lib/samagotchi/hooks/registry.rb +6 -2
  45. data/lib/samagotchi/host_registry.rb +22 -4
  46. data/lib/samagotchi/idle_recap.rb +7 -2
  47. data/lib/samagotchi/idle_scheduler.rb +1 -1
  48. data/lib/samagotchi/installed_versions.rb +91 -0
  49. data/lib/samagotchi/live_versions.rb +5 -3
  50. data/lib/samagotchi/llm/usage.rb +1 -1
  51. data/lib/samagotchi/memory_bundle/builder.rb +25 -6
  52. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  53. data/lib/samagotchi/memory_bundle/installer.rb +79 -13
  54. data/lib/samagotchi/memory_bundle/shipped_update.rb +3 -1
  55. data/lib/samagotchi/memory_bundle/status.rb +17 -5
  56. data/lib/samagotchi/memory_bundle/uninstaller.rb +7 -7
  57. data/lib/samagotchi/memory_paths.rb +6 -0
  58. data/lib/samagotchi/model_overlay.rb +39 -0
  59. data/lib/samagotchi/model_profile.rb +4 -0
  60. data/lib/samagotchi/relay_desk.rb +9 -0
  61. data/lib/samagotchi/self_report.rb +38 -2
  62. data/lib/samagotchi/session.rb +35 -6
  63. data/lib/samagotchi/session_commands.rb +13 -2
  64. data/lib/samagotchi/session_manager.rb +166 -11
  65. data/lib/samagotchi/session_metrics.rb +260 -32
  66. data/lib/samagotchi/sessions_command.rb +43 -4
  67. data/lib/samagotchi/system_prompt.rb +10 -1
  68. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
  69. data/lib/samagotchi/terminal_ui/formatting.rb +52 -1
  70. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  71. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  72. data/lib/samagotchi/token_usage.rb +57 -41
  73. data/lib/samagotchi/tool_runner.rb +21 -2
  74. data/lib/samagotchi/tools/builtins.rb +21 -5
  75. data/lib/samagotchi/tools/execute.rb +9 -8
  76. data/lib/samagotchi/turn_note.rb +3 -2
  77. data/lib/samagotchi/update_command.rb +33 -12
  78. data/lib/samagotchi/version.rb +1 -1
  79. data/lib/samagotchi/web/app.rb +83 -11
  80. data/lib/samagotchi/web/public/app.js +135 -43
  81. data/lib/samagotchi/web/public/card.js +34 -2
  82. data/lib/samagotchi/web/public/ctx.js +59 -0
  83. data/lib/samagotchi/web/public/data.js +6 -0
  84. data/lib/samagotchi/web/public/format.js +15 -2
  85. data/lib/samagotchi/web/public/index.html +37 -2
  86. data/lib/samagotchi/web/public/model_pick.js +6 -2
  87. data/lib/samagotchi/web/public/model_picker.js +2 -1
  88. data/lib/samagotchi/web/public/question_card.js +14 -1
  89. data/lib/samagotchi/web/public/question_cards.js +17 -11
  90. data/lib/samagotchi/web/public/scope.js +15 -6
  91. data/lib/samagotchi/web/public/session_state.js +4 -0
  92. data/lib/samagotchi/web/public/timing.js +6 -2
  93. data/lib/samagotchi/web/public/turn_events.js +108 -4
  94. data/lib/samagotchi/web/public/update_notice.js +87 -14
  95. data/lib/samagotchi/web/server.rb +28 -1
  96. data/lib/samagotchi/web/session_hub.rb +30 -3
  97. data/lib/samagotchi/web/session_summary.rb +11 -2
  98. data/lib/samagotchi/worker.rb +25 -8
  99. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  100. data/lib/samagotchi/worker_sidecar.rb +7 -5
  101. 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
- stream = @client.follow(client_id: @client_id) { |event| queue << [:event, event] }
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
- TOKEN_RE = /(?:\A|(?<=\s))@(?:"([^"]+)"|'([^']+)'|(\S+))/
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 extracts real server-reported token counts from a model API
5
- # stream payload, transport-agnostically.
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`. mlx_lm.server (OpenAI-compatible)
10
- # emits a `usage` object with `prompt_tokens` / `completion_tokens`.
9
+ # `tokens_evaluated` / `tokens_predicted`. OpenAI-compatible servers emit a
10
+ # `usage` object with `prompt_tokens` / `completion_tokens`.
11
11
  #
12
- # Returns nil when no server token data is present, so a caller can fall back
13
- # to a chars/4 heuristic. The reported values are cumulative per request, so
14
- # callers should take the running MAX across chunks (see SessionMetrics).
15
- class TokenUsage
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 [Hash, nil] { prompt_tokens:, completion_tokens:, source: :server }
28
+ # @return [TokenUsage, nil]
21
29
  def from_payload(payload)
22
30
  payload_hash = payload.is_a?(Hash) ? payload : {}
23
- usage = payload_hash["usage"] || payload_hash[:usage]
24
- usage = {} unless usage.is_a?(Hash)
31
+ usage = hash_at(payload_hash, :usage)
32
+ timings = hash_at(payload_hash, :timings)
25
33
 
26
34
  prompt_tokens = first_positive(
27
- usage["prompt_tokens"],
28
- usage[:prompt_tokens],
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["completion_tokens"],
41
- usage[:completion_tokens],
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
- next if value.nil?
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], marker_env: parent_env(kctx))
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
- def parent_env(kctx)
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
- SESSION_MODEL_ENV => model.empty? ? nil : model }
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
- Execute.send(:close_quietly, @io)
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
- def self.close_quietly(io)
213
- io.close unless io.closed?
214
- rescue IOError
215
- nil
210
+ private
211
+
212
+ def close_quietly
213
+ @io.close unless @io.closed?
214
+ rescue IOError
215
+ nil
216
+ end
216
217
  end
217
- private_class_method :close_quietly
218
+ private_constant :OutputReader
218
219
 
219
220
  def self.terminate_process_tree(pid)
220
221
  signal_process(pid, "TERM")
@@ -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 answer above ends where it was cut off." : "no answer had been shown."
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 they idle out (30 min) or
38
- chi sessions stop ID; a running chi web needs a restart.
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
- web = LiveVersions.web_version(host, port)
250
- if web && web != VERSION
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
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.18.1"
4
+ VERSION = "0.20.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end