samagotchi 0.16.0 → 0.17.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +13 -0
  5. data/docs/cli.md +4 -2
  6. data/docs/configuration.md +4 -4
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/memory.md +39 -0
  9. data/docs/plugins.md +2 -0
  10. data/docs/sessions.md +3 -2
  11. data/docs/sub-agent.md +1 -0
  12. data/docs/testing.md +47 -0
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  14. data/lib/samagotchi/bridge.rb +38 -7
  15. data/lib/samagotchi/bundle_command.rb +13 -2
  16. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  18. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  19. data/lib/samagotchi/engine.rb +31 -5
  20. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  21. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  22. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  23. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  24. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  25. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  26. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  27. data/lib/samagotchi/memory_bundle/trash.rb +57 -0
  28. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  29. data/lib/samagotchi/plugin/context.rb +14 -2
  30. data/lib/samagotchi/self_report.rb +49 -3
  31. data/lib/samagotchi/send_command.rb +45 -6
  32. data/lib/samagotchi/session_metrics.rb +137 -48
  33. data/lib/samagotchi/system_prompt.rb +45 -4
  34. data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -3
  35. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  36. data/lib/samagotchi/terminal_ui.rb +24 -0
  37. data/lib/samagotchi/tools/builtins.rb +11 -3
  38. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  39. data/lib/samagotchi/tools/peers.rb +3 -2
  40. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +2 -1
  43. data/lib/samagotchi/web/public/app.js +37 -28
  44. data/lib/samagotchi/web/public/notify.js +7 -3
  45. data/lib/samagotchi/web/public/stage_view.js +13 -1
  46. data/lib/samagotchi/web/public/strip.js +11 -0
  47. data/lib/samagotchi/web/public/timing.js +26 -12
  48. data/lib/samagotchi/web/public/turn_events.js +13 -3
  49. data/lib/samagotchi/worker.rb +28 -2
  50. data/lib/samagotchi/worker_sidecar.rb +4 -1
  51. metadata +3 -1
@@ -7,6 +7,7 @@ require_relative "context_window"
7
7
  require_relative "session"
8
8
  require_relative "log_path"
9
9
  require_relative "model_profile"
10
+ require_relative "model_overlay"
10
11
  require_relative "thinking"
11
12
  require_relative "served_model"
12
13
  require_relative "host_registry"
@@ -39,7 +40,8 @@ module Samagotchi
39
40
  end
40
41
 
41
42
  def fields(env: ENV)
42
- model = model_name
43
+ default = model_name
44
+ model = session_model(env) || default
43
45
  [
44
46
  ["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
45
47
  ["source", "#{SOURCE_DIR} (#{install_kind})"],
@@ -49,7 +51,8 @@ module Samagotchi
49
51
  ["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
50
52
  ["sessions", Session.default_state_dir(env: env)],
51
53
  ["log", log_path(env)],
52
- ["model", model || "(not configured)"],
54
+ ["model", model ? model_label(model, default, env) : "(not configured)"],
55
+ ["model key", model ? model_key_for(model, env) : "-"],
53
56
  ["host", model ? host_for(model, env) : "-"],
54
57
  ["api key", model ? api_key_for(model, env) : "-"],
55
58
  ["loop", model ? loop_for(model, env) : "-"],
@@ -125,6 +128,46 @@ module Samagotchi
125
128
  nil
126
129
  end
127
130
 
131
+ SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
132
+ PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
133
+
134
+ # The model of the session this chi self runs in (its execute exports
135
+ # SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
136
+ # outside a session's commands.
137
+ def session_model(env)
138
+ name = env[SESSION_MODEL_ENV].to_s.strip
139
+ name.empty? ? nil : name
140
+ end
141
+
142
+ # "splash:x (this session abcd1234; default main:y)", "main:y (this
143
+ # session abcd1234, the default)", or outside a session "main:y (default)".
144
+ def model_label(model, default, env)
145
+ return "#{model} (default)" unless session_model(env)
146
+
147
+ id = env[PARENT_SESSION_ENV].to_s
148
+ session = id.empty? || id == "chi" ? "this session" : "this session #{id[0, 8]}"
149
+ return "#{model} (#{session}, the default)" if default && same_model?(model, default, env)
150
+
151
+ "#{model} (#{session}; default #{default || "not configured"})"
152
+ end
153
+
154
+ def same_model?(one, other, env)
155
+ return true if one == other
156
+
157
+ ConfigFile.model_ref(one, env: env).ref == ConfigFile.model_ref(other, env: env).ref
158
+ rescue StandardError
159
+ false
160
+ end
161
+
162
+ # The memory overlay key (ModelOverlay.key_for the bare id, as the
163
+ # engine keys memory_write current_model_only): which `<name>.<key>.md`
164
+ # overlays are this model's.
165
+ def model_key_for(model, env)
166
+ ModelOverlay.key_for(HostRegistry.new(env: env).bare_name(model)) || "-"
167
+ rescue StandardError
168
+ ModelOverlay.key_for(model) || "-"
169
+ end
170
+
128
171
  def model_name
129
172
  ModelProfile.required_model_name
130
173
  rescue ArgumentError
@@ -147,6 +190,8 @@ module Samagotchi
147
190
  entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
148
191
  end
149
192
 
193
+ NO_PROPS = "reported per turn (the server has no /props)"
194
+
150
195
  # chi self's one server call: a single-model llama.cpp answers any name
151
196
  # with the model it loaded, and its /props names it (model_alias). One
152
197
  # GET with the probe's short timeouts; a remote host isn't asked.
@@ -157,10 +202,11 @@ module Samagotchi
157
202
  bare = target.bare_model
158
203
  return "-" unless entry
159
204
  return "reported per turn (remote host)" if entry.remote?
205
+ return NO_PROPS if entry.chat?
160
206
 
161
207
  client = registry.client_for(entry)
162
208
  props = client.server_props(model: bare)
163
- return "reported per turn (the server has no /props)" if props.nil?
209
+ return NO_PROPS if props.nil? || props.status == :http_error
164
210
  return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
165
211
 
166
212
  served = ServedModel.from_props(props)
@@ -37,6 +37,8 @@ module Samagotchi
37
37
  chi send --wait [--timeout S] [--format json] ID
38
38
  Sends a message to each session, as if typed in it: a turn starts,
39
39
  or a running one picks it up. A stopped session's worker starts.
40
+ A session command (/model x, !cmd) runs as the command; --wait
41
+ then has no reply to wait for (exit 0, status command).
40
42
  -m TEXT the message; stdin, when piped too, goes above it as a
41
43
  quote (context); without -m, stdin is the message
42
44
  --image PATH
@@ -64,7 +66,7 @@ module Samagotchi
64
66
  (--wait) one JSON object on stdout, whatever the end:
65
67
  status answered (text), question (question,
66
68
  answer_with), running, or failed, canceled,
67
- no_answer, error, worker_gone, stopped (detail)
69
+ no_answer, error, worker_gone, stopped, command (detail)
68
70
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
69
71
  question waits, 4 still running (--timeout), 130 Ctrl-C.
70
72
  Only sessions on this machine. Answers show in the attached TUI
@@ -246,23 +248,27 @@ module Samagotchi
246
248
  error_line("chi send: refused: #{refusal}")
247
249
  return 1
248
250
  end
251
+ # With images the web's way: idle (the message names it in the
252
+ # lists), the images copied in, then the message as a turn. So too a
253
+ # line that may be a session command (/model x): its worker's answer
254
+ # says whether it ran as one.
255
+ through_worker = images || command_like?(prompt)
249
256
  begin
250
- # With images the web's way: idle (the message names it in the
251
- # lists), the images copied in, then the message as a turn.
252
- start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
257
+ start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
253
258
  session = SessionManager.spawn_session(**start, working_directory: options[:dir],
254
259
  model_name: options[:model], state_dir: @state_dir)
255
260
  rescue StandardError => e
256
261
  error_line("chi send: could not start a session: #{e.message}")
257
262
  return 1
258
263
  end
259
- if images
264
+ if through_worker
260
265
  return 1 unless deliver_new(session, prompt)
261
266
  else
262
267
  @info.puts("#{session.id} started")
263
268
  end
264
269
  @session_id = session.id
265
270
  return 0 unless options[:wait]
271
+ return report_command(session.id) if @sent_command
266
272
 
267
273
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
268
274
  timeout: options[:timeout])
@@ -297,6 +303,7 @@ module Samagotchi
297
303
  cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
298
304
  baseline = baseline_of(session)
299
305
  return 1 unless deliver(id, prompt)
306
+ return report_command(id) if @sent_command
300
307
 
301
308
  wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
302
309
  end
@@ -352,10 +359,36 @@ module Samagotchi
352
359
  return false
353
360
  end
354
361
 
355
- @info.puts("#{session.id} started#{with_images}")
362
+ @sent_command = command_ack?(result[:ack])
363
+ @info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
356
364
  true
357
365
  end
358
366
 
367
+ SENT_COMMAND = "sent as a session command"
368
+
369
+ # Whether the worker took the message as a session command (a POST
370
+ # /turn whose line is one answers with its command_id).
371
+ def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
372
+
373
+ # Whether +text+ may be a session command, which only its worker knows
374
+ # (a bundle's too).
375
+ def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
376
+
377
+ # --wait after a message that ran as a session command: no reply comes
378
+ # for it. Its output shows where the session is open.
379
+ # @return [Integer] 0
380
+ def report_command(id)
381
+ detail = "#{SENT_COMMAND}: no reply to wait for (its output shows in chi --attach #{id} or the web)"
382
+ if @json
383
+ @stdout.puts(JSON.generate({ status: "command", session_id: id, detail: detail }))
384
+ @stdout.flush
385
+ @reported = true
386
+ else
387
+ @stderr.puts("#{id[0, 8]} #{detail}")
388
+ end
389
+ CLI::Exit::OK
390
+ end
391
+
359
392
  # @return [Boolean] whether the message was queued
360
393
  def deliver(id, prompt)
361
394
  short = id[0, 8]
@@ -373,6 +406,12 @@ module Samagotchi
373
406
  return false
374
407
  end
375
408
 
409
+ @sent_command = command_ack?(result[:ack])
410
+ if @sent_command
411
+ @info.puts("#{short} #{SENT_COMMAND}")
412
+ return true
413
+ end
414
+
376
415
  # A busy worker runs a message with images as its own next turn
377
416
  # rather than merging it into the running one.
378
417
  note = if owner.nil? then " (started its worker)"
@@ -17,15 +17,24 @@ module Samagotchi
17
17
  #
18
18
  # Each finished turn leaves a record (status, timings, model, prompt and
19
19
  # completion tokens, tool calls, iterations, retries); session totals are
20
- # sums over the records, so a worker that stops and wakes again (a new
21
- # collector) keeps counting: #session_id= loads the records already saved.
20
+ # sums over the records, kept as running counters as records are added, so
21
+ # a worker that stops and wakes again (a new collector) keeps counting:
22
+ # #session_id= loads the records already saved.
22
23
  # Only prompt and completion counts, which every backend reports the same
23
24
  # way, are kept.
24
25
  #
25
- # A snapshot is surfaced via Engine#session_state_snapshot and (optionally)
26
- # persisted to a sibling analytics.json next to the session file. The event
27
- # trail itself goes to the debug log (LogSubscriber).
26
+ # A snapshot is surfaced via Engine#session_state_snapshot (the Bridge's
27
+ # /state, /snapshot, /stats and SSE frames) and persisted to a sibling
28
+ # analytics.json next to the session file. The live snapshot carries the
29
+ # totals and only the recent records (the newest finished turn's, plus any
30
+ # not yet saved, at most LIVE_RECORDS_CAP turns), so it stays the same size
31
+ # however long the session runs; analytics.json holds every record. The
32
+ # event trail itself goes to the debug log (LogSubscriber).
28
33
  class SessionMetrics
34
+ # The most turns (with their tool calls) a live snapshot carries: the
35
+ # unsaved ones of a collector whose saves keep failing stay bounded.
36
+ LIVE_RECORDS_CAP = 20
37
+
29
38
  # Per-turn transient state, reset on each turn_started.
30
39
  TurnState = Struct.new(
31
40
  :session_id,
@@ -118,6 +127,11 @@ module Samagotchi
118
127
  @turn = nil
119
128
  @turn_records = []
120
129
  @tool_records = []
130
+ # How many turn records the last successful save (or the load) holds,
131
+ # and how many were loaded (a woken collector's live records start empty).
132
+ @persisted_turns = 0
133
+ @loaded_turns = 0
134
+ reset_totals
121
135
  end
122
136
 
123
137
  # /model: the window and prompt profile a turn reported were the old
@@ -213,51 +227,31 @@ module Samagotchi
213
227
  # The session totals are sums over the turn records (the ones loaded from
214
228
  # disk and this process's), with the running turn's calls, iterations and
215
229
  # finished generations counted at once, so /stats agrees mid-turn.
230
+ # :turn_records / :tool_records are the recent ones only: the newest
231
+ # finished turn's and any not yet saved (at most LIVE_RECORDS_CAP turns),
232
+ # with the running turn's finished calls; the full history is the
233
+ # session's analytics.json.
216
234
  # @return [Hash] the current summary snapshot
217
235
  def snapshot
218
- @mutex.synchronize do
219
- records = @turn_records
220
- turn = @turn
221
- in_flight = turn ? turn.tool_calls_by_id.values : []
222
- tools = @tool_records + in_flight
223
- {
224
- session_id: @session_id,
225
- turns: records.size + (turn ? 1 : 0),
226
- cancellations: records.count { |record| record[:status] == "canceled" },
227
- tokens: {
228
- prompt_sum: sum(records, :prompt_tokens_sum) + (turn&.prompt_sum || 0),
229
- completion_sum: sum(records, :completion_tokens) + (turn&.completion_sum || 0),
230
- source: combined_source(records.map { |record| record[:token_source] } + (turn&.token_sources || []))
231
- },
232
- context: context_block(records),
233
- profile: @profile,
234
- profile_source: @profile_source,
235
- served_model: @served_model,
236
- served_model_for: @served_model_for,
237
- tool_calls_total: tools.size,
238
- tool_calls_by_tool: tools.map { |tool| tool[:tool].to_s }.reject(&:empty?).tally,
239
- tool_errors: @tool_records.count { |tool| tool[:status] == "error" },
240
- iterations_total: sum(records, :iterations) + (turn&.iteration_count || 0),
241
- gen_latency_ms: (sum(records, :gen_ms) + (turn&.gen_latency_accum || 0)).round,
242
- retries: sum(records, :retries) + (turn&.retries || 0),
243
- started_at: @started_at,
244
- last_activity_at: @last_activity_at,
245
- session_duration_ms: elapsed_ms(@session_started_monotonic),
246
- turn_records: @turn_records.map(&:dup),
247
- tool_records: @tool_records.map(&:dup),
248
- active_turn: active_turn_snapshot,
249
- active_tools: active_tool_snapshots
250
- }
251
- end
236
+ @mutex.synchronize { build_snapshot(:recent) }
237
+ end
238
+
239
+ # The served model a generation last reported and the name asked for
240
+ # then, without building a snapshot (Engine#served_model).
241
+ # @return [Hash] :served_model, :served_model_for
242
+ def served_report
243
+ @mutex.synchronize { { served_model: @served_model, served_model_for: @served_model_for } }
252
244
  end
253
245
 
254
- # Persist the summary snapshot to a sibling analytics.json in the session
255
- # directory. Atomic write; failures are swallowed (analytics must never
256
- # break the running session).
246
+ # Persist the summary snapshot, with every record, to a sibling
247
+ # analytics.json in the session directory. Atomic write; failures are
248
+ # swallowed (analytics must never break the running session).
257
249
  # @param state_dir [String, nil]
258
250
  # @return [Boolean] true on success
259
251
  def persist(state_dir: nil)
260
- sid = @mutex.synchronize { @session_id }
252
+ sid, data, turn_count = @mutex.synchronize do
253
+ [@session_id, build_snapshot(:all).merge(active_turn: nil, active_tools: []), @turn_records.size]
254
+ end
261
255
  return false if sid.nil? || sid.to_s.empty?
262
256
 
263
257
  # Omitting state_dir lets Session.session_dir fall back to the default
@@ -265,7 +259,8 @@ module Samagotchi
265
259
  # File.join.
266
260
  FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
267
261
  path = File.join(dir, "analytics.json")
268
- AtomicFile.write(path, JSON.pretty_generate(snapshot.merge(active_turn: nil, active_tools: [])) + "\n")
262
+ AtomicFile.write(path, JSON.pretty_generate(data) + "\n")
263
+ @mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
269
264
  true
270
265
  rescue StandardError
271
266
  false
@@ -273,6 +268,58 @@ module Samagotchi
273
268
 
274
269
  private
275
270
 
271
+ # Caller holds the mutex. +records+: :recent (the live snapshot) or :all
272
+ # (analytics.json).
273
+ def build_snapshot(records_mode)
274
+ totals = @totals
275
+ turn = @turn
276
+ in_flight = turn ? turn.tool_calls_by_id.values : []
277
+ {
278
+ session_id: @session_id,
279
+ turns: @turn_records.size + (turn ? 1 : 0),
280
+ cancellations: totals[:cancellations],
281
+ tokens: {
282
+ prompt_sum: totals[:prompt_sum] + (turn&.prompt_sum || 0),
283
+ completion_sum: totals[:completion_sum] + (turn&.completion_sum || 0),
284
+ source: combined_source(totals[:token_sources] + (turn&.token_sources || []))
285
+ },
286
+ context: context_block(@turn_records),
287
+ profile: @profile,
288
+ profile_source: @profile_source,
289
+ served_model: @served_model,
290
+ served_model_for: @served_model_for,
291
+ tool_calls_total: @tool_records.size + in_flight.size,
292
+ tool_calls_by_tool: tally_into(totals[:by_tool].dup, in_flight),
293
+ tool_errors: totals[:tool_errors],
294
+ iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
295
+ gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
296
+ retries: totals[:retries] + (turn&.retries || 0),
297
+ started_at: @started_at,
298
+ last_activity_at: @last_activity_at,
299
+ session_duration_ms: elapsed_ms(@session_started_monotonic),
300
+ **records_view(records_mode),
301
+ active_turn: active_turn_snapshot,
302
+ active_tools: active_tool_snapshots
303
+ }
304
+ end
305
+
306
+ # The records a snapshot carries, copied. :all is every record; :recent
307
+ # the newest turn this collector finished (live before its save lands)
308
+ # and the unsaved ones (capped), and the tool records of those turns and
309
+ # of the running one. Records are appended in turn order, so both come
310
+ # from the tails. Caller holds the mutex.
311
+ def records_view(mode)
312
+ return { turn_records: @turn_records.map(&:dup), tool_records: @tool_records.map(&:dup) } if mode == :all
313
+
314
+ unsaved = @turn_records.size - @persisted_turns
315
+ count = unsaved.clamp(@turn_records.size > @loaded_turns ? 1 : 0, LIVE_RECORDS_CAP)
316
+ turns = @turn_records.last(count)
317
+ ids = turns.to_set { |record| record[:id] }
318
+ ids << @turn.id if @turn
319
+ tools = @tool_records.reverse_each.take_while { |tool| ids.include?(tool[:turn_id]) }.reverse
320
+ { turn_records: turns.map(&:dup), tool_records: tools.map(&:dup) }
321
+ end
322
+
276
323
  def begin_turn(event)
277
324
  @mutex.synchronize do
278
325
  unless @session_id || event[:session_id].to_s.empty?
@@ -328,6 +375,7 @@ module Samagotchi
328
375
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
329
376
  record.merge!(turn_token_fields(@turn))
330
377
  @turn_records << record
378
+ count_turn(record)
331
379
  end
332
380
  @turn = nil
333
381
  end
@@ -439,7 +487,7 @@ module Samagotchi
439
487
  return unless active
440
488
 
441
489
  finished_at = now
442
- @tool_records << active.slice(
490
+ record = active.slice(
443
491
  :id, :turn_id, :iteration, :call_index, :tool, :started_at
444
492
  ).merge(
445
493
  status: status.empty? ? "ok" : status,
@@ -447,6 +495,8 @@ module Samagotchi
447
495
  # Less a guardrail approval wait (ToolRunner's waited_ms).
448
496
  duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
449
497
  )
498
+ @tool_records << record
499
+ count_tool(record)
450
500
  end
451
501
  end
452
502
 
@@ -548,8 +598,15 @@ module Samagotchi
548
598
  prior = JSON.parse(File.read(path))
549
599
  return unless prior.is_a?(Hash)
550
600
 
551
- @turn_records = loaded_records(prior["turn_records"]) + @turn_records
601
+ loaded_turns = loaded_records(prior["turn_records"])
602
+ @turn_records = loaded_turns + @turn_records
552
603
  @tool_records = loaded_records(prior["tool_records"]) + @tool_records
604
+ @persisted_turns = @loaded_turns = loaded_turns.size
605
+ # Once per collector: recount in record order (the tally's key order
606
+ # stays first-seen).
607
+ reset_totals
608
+ @turn_records.each { |record| count_turn(record) }
609
+ @tool_records.each { |record| count_tool(record) }
553
610
  @started_at = earliest_timestamp(prior["started_at"], @started_at)
554
611
  @last_activity_at ||= prior["last_activity_at"]
555
612
  # The window last seen, until this process's first generation reports.
@@ -579,8 +636,40 @@ module Samagotchi
579
636
  }
580
637
  end
581
638
 
582
- def sum(records, key)
583
- records.sum { |record| record[key].is_a?(Numeric) ? record[key] : 0 }
639
+ # The session totals over the finished records, as running counters
640
+ # (#snapshot adds the running turn). Caller holds the mutex.
641
+ def reset_totals
642
+ @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
643
+ gen_ms: 0, retries: 0, by_tool: {}, tool_errors: 0 }
644
+ end
645
+
646
+ def count_turn(record)
647
+ totals = @totals
648
+ totals[:cancellations] += 1 if record[:status] == "canceled"
649
+ totals[:prompt_sum] += number(record[:prompt_tokens_sum])
650
+ totals[:completion_sum] += number(record[:completion_tokens])
651
+ totals[:iterations] += number(record[:iterations])
652
+ totals[:gen_ms] += number(record[:gen_ms])
653
+ totals[:retries] += number(record[:retries])
654
+ source = record[:token_source]
655
+ totals[:token_sources] |= [source.to_s] unless source.nil?
656
+ end
657
+
658
+ def count_tool(record)
659
+ @totals[:tool_errors] += 1 if record[:status] == "error"
660
+ tally_into(@totals[:by_tool], [record])
661
+ end
662
+
663
+ def tally_into(counts, tools)
664
+ tools.each do |tool|
665
+ name = tool[:tool].to_s
666
+ counts[name] = counts.fetch(name, 0) + 1 unless name.empty?
667
+ end
668
+ counts
669
+ end
670
+
671
+ def number(value)
672
+ value.is_a?(Numeric) ? value : 0
584
673
  end
585
674
 
586
675
  # server, estimate, or mixed when both kinds of counts (or a turn that
@@ -23,6 +23,15 @@ module Samagotchi
23
23
  class SystemPrompt
24
24
  AGENT_DESCRIPTION_FILE = "AGENT.md"
25
25
 
26
+ # The model a session runs on, as the prompt names it: the resolved
27
+ # ref, its host ("name, host:port" or url), the memory overlay key, and
28
+ # what a llama.cpp server says it serves when that differs (else nil).
29
+ # fallback_key: the key an alias typed for the model used to key overlays
30
+ # by (read when +key+ has none, as memory_read does); not shown.
31
+ ModelIdentity = Data.define(:ref, :host, :key, :served, :fallback_key) do
32
+ def initialize(ref:, host:, key:, served: nil, fallback_key: nil) = super
33
+ end
34
+
26
35
  # Always auto-loaded from the system scope unless muted (B-light).
27
36
  DEFAULT_SYSTEM_MEMORIES = %w[identity].freeze
28
37
 
@@ -34,10 +43,12 @@ module Samagotchi
34
43
  # @param tools [#call] → Tools::Registry
35
44
  # @param session [#call] → Session, nil
36
45
  # @param thinking [#call] → Symbol, the effective model's level (Thinking)
46
+ # @param model [#call] → ModelIdentity, nil (the section is left out)
37
47
  # @param memories [Array<String>] the --memory list
38
48
  # @param muted_memory_names [Array<String>] normalized (MutedMemories)
39
- def initialize(profile:, tools:, session:, thinking:, memories: [], muted_memory_names: [])
49
+ def initialize(profile:, tools:, session:, thinking:, model: -> {}, memories: [], muted_memory_names: [])
40
50
  @profile_lookup = profile
51
+ @model_lookup = model
41
52
  @tools_lookup = tools
42
53
  @session_lookup = session
43
54
  @thinking_lookup = thinking
@@ -223,7 +234,7 @@ module Samagotchi
223
234
  "Project memories:\n#{project_index}",
224
235
  "System memories:\n#{system_index}"
225
236
  ].join("\n\n")
226
- [thinking_token + base, rg_guidance, project_description, project_location, current_session, memory_sections, system_identity_section, explicit_memory_section].compact.join("\n")
237
+ [thinking_token + base, rg_guidance, project_description, project_location, current_model, current_session, memory_sections, system_identity_section, explicit_memory_section].compact.join("\n")
227
238
  end
228
239
 
229
240
  # B-light: auto-preload the built-in identity memory.
@@ -235,7 +246,7 @@ module Samagotchi
235
246
  DEFAULT_SYSTEM_MEMORIES.each do |name|
236
247
  next if memory_muted?(name)
237
248
 
238
- body = Tools::MemoryRead.call(name, scope: "system")
249
+ body = Tools::MemoryRead.call(name, scope: "system", **overlay_keys)
239
250
  next if body.start_with?("Error:")
240
251
  next if body.strip.empty?
241
252
 
@@ -248,6 +259,17 @@ module Samagotchi
248
259
 
249
260
  # ── Memory helpers ─────────────────────────────────────────────────────────
250
261
 
262
+ # The session model's overlay keys, so the prompt's own memory bodies
263
+ # (identity, preloads) get `<name>.<key>.md` as memory_read gives it.
264
+ def overlay_keys
265
+ model = @model_lookup.call
266
+ return {} unless model&.key
267
+
268
+ { model_key: model.key, fallback_model_key: model.fallback_key }
269
+ rescue StandardError
270
+ {}
271
+ end
272
+
251
273
  # The scope's index text without the muted memories' lines.
252
274
  def read_memory_index(scope)
253
275
  BundleNeeds.annotate_index(MutedMemories.filter_index(Tools::MemoryRead.call("", scope: scope), @muted_memory_names), scope)
@@ -297,7 +319,7 @@ module Samagotchi
297
319
  names = raw.split(",").map(&:strip).reject(&:empty?)
298
320
  names.each do |name|
299
321
  scope, actual_name = split_memory_scope(name)
300
- body = Tools::MemoryRead.call(actual_name, scope: scope)
322
+ body = Tools::MemoryRead.call(actual_name, scope: scope, **overlay_keys)
301
323
  if body.start_with?("Error:")
302
324
  source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
303
325
  Log.warn(:memory, "preload_failed", echo: "Warning: #{source} could not be loaded (#{body})", memory: name)
@@ -378,6 +400,25 @@ module Samagotchi
378
400
  path
379
401
  end
380
402
 
403
+ # The model this session runs on. A model guesses its name from
404
+ # training (a fine-tune often knows only its base model's), so the line
405
+ # says to answer from here. It depends only on the effective model, and
406
+ # the prompt is rebuilt only on a switch (#reset!): no per-turn churn.
407
+ def current_model
408
+ model = @model_lookup.call
409
+ return nil unless model && !model.ref.to_s.empty?
410
+
411
+ details = [model.host, model.key && "model key #{model.key}", model.served && "the server says it serves #{model.served}"]
412
+ details = details.compact.reject(&:empty?)
413
+ where = details.empty? ? "" : " (#{details.join("; ")})"
414
+ "Model: this session runs on #{model.ref}#{where}.\n" \
415
+ "Asked which model you are, answer with this line, not from training: a fine-tuned model often knows only " \
416
+ "its base model's name, but this session runs what is named here; it changes only with /model. " \
417
+ "Guidance for this model only goes in memory overlays: memory_write current_model_only: true."
418
+ rescue StandardError
419
+ nil
420
+ end
421
+
381
422
  # Fixed for the session's lifetime, so it doesn't churn the prompt cache.
382
423
  # Omitted until a session is attached (run_turn / TerminalUI set it). A
383
424
  # delegated session (parent_id set) is told who reads its reply.
@@ -310,7 +310,13 @@ module Samagotchi
310
310
  return detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if line.nil?
311
311
 
312
312
  text = line.strip
313
- return answer_question(text) if @question
313
+ if @question
314
+ # /detach leaves the question open, as Ctrl-D does; any other
315
+ # line (/exit too) is read as the answer.
316
+ return submit(nil) if command_registry.lookup_local(text)&.id == :detach
317
+
318
+ return answer_question(text)
319
+ end
314
320
 
315
321
  # The terminal's own commands (SessionCommands registers them, the
316
322
  # REPL reads the same words). Before the continue offer: the
@@ -449,7 +455,12 @@ module Samagotchi
449
455
  # too, so nothing waits here.
450
456
  def send_command(line)
451
457
  reply = @client.post_command(line: line, client_id: @client_id)
452
- return if reply.status == 202
458
+ if reply.status == 202
459
+ # Input from a pipe ends once its output is in (#command_ran).
460
+ command_id = reply.json&.fetch("command_id", nil)
461
+ @open_ids << command_id if command_id && @wait_at_eof
462
+ return
463
+ end
453
464
  return @screen.commit(stale_worker("run commands")) if reply.status == 404
454
465
  return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
455
466
 
@@ -473,6 +484,7 @@ module Samagotchi
473
484
  end
474
485
 
475
486
  def command_ran(event)
487
+ @open_ids.delete(event[:command_id])
476
488
  @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
477
489
  output = event[:output].to_s
478
490
  if output.empty?
@@ -939,7 +951,8 @@ module Samagotchi
939
951
  return if text.empty?
940
952
 
941
953
  @screen.commit("#{paint(PROMPT, 92)}#{text}")
942
- send_prompt(text)
954
+ # As if typed: a session command runs as the command.
955
+ command_registry.command?(text) ? send_command(text) : send_prompt(text)
943
956
  end
944
957
 
945
958
  def render_join_header(messages)
@@ -63,7 +63,8 @@ module Samagotchi
63
63
  when :generation_chunk
64
64
  @view.generation_feedback_chunk(event)
65
65
  when :tool_call_started
66
- @tool_started_at[tool_call_key(event)] = @clock.call
66
+ # A joined running call (a snapshot's replay) started elapsed_ms ago.
67
+ @tool_started_at[tool_call_key(event)] = @clock.call - (event[:elapsed_ms].to_f / 1000)
67
68
  @view.tool_call_feedback_started(event)
68
69
  when :tool_call_completed
69
70
  @view.clear_generation_retry
@@ -232,6 +232,10 @@ module Samagotchi
232
232
  @engine.session = session
233
233
  messages = messages_for(session)
234
234
 
235
+ # As typed at the prompt: a session command (/models, !ls) runs as
236
+ # the command, not as a turn.
237
+ return run_one_shot_command(session) if @prompt && @non_interactive && command_registry.command?(@prompt)
238
+
235
239
  if @prompt && @non_interactive
236
240
  # Headless / CI mode: run directly without TTY rendering.
237
241
  ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir)
@@ -281,6 +285,22 @@ module Samagotchi
281
285
 
282
286
  EMPTY_ANSWER_ERROR = "chi: the model gave an empty answer"
283
287
 
288
+ # `chi -p "/models" --non-interactive`: the command's output; a refused
289
+ # one fails the run (bin/chi exits 1). The session is saved only when
290
+ # the command changed it.
291
+ # @return [Symbol, nil] :turn_failed when the command was refused
292
+ def run_one_shot_command(session)
293
+ line = @prompt.strip
294
+ result = begin
295
+ @commands.run(line)
296
+ rescue StandardError => e
297
+ SessionCommands::Result.new(status: :error, output: "#{line.split.first}: #{e.message}", changed: [])
298
+ end
299
+ @surface.commit(result.output) unless result.output.to_s.empty?
300
+ session.save unless Array(result.changed).empty?
301
+ :turn_failed if result.status == :error
302
+ end
303
+
284
304
  # Delete the scratch session, the last thing the REPL does. Quiet after
285
305
  # a one-shot or on the way out of an error or a signal.
286
306
  def delete_scratch(session, quiet:)
@@ -1184,6 +1204,10 @@ module Samagotchi
1184
1204
 
1185
1205
  # A ? read of its own, off a terminal (specs, pipes): $stdin.gets.
1186
1206
  def read_choice_line(question_prompt)
1207
+ # Off a terminal the ? prompt can't be taken back: at the input's end
1208
+ # it would stay bare above the summary. eof? waits as gets would.
1209
+ return nil if !$stdin.tty? && $stdin.eof?
1210
+
1187
1211
  @surface.set_slot(:editor, [question_prompt])
1188
1212
  line = $stdin.gets
1189
1213
  # A pipe doesn't echo: the line completes the ? prompt here, once,