samagotchi 0.15.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +23 -0
  5. data/docs/cli.md +7 -4
  6. data/docs/configuration.md +7 -5
  7. data/docs/guardrails.md +16 -2
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/memory.md +39 -0
  10. data/docs/plugins.md +2 -0
  11. data/docs/sessions.md +10 -4
  12. data/docs/sub-agent.md +16 -3
  13. data/docs/testing.md +47 -0
  14. data/lib/samagotchi/answer_display.rb +6 -2
  15. data/lib/samagotchi/bridge/card_store.rb +16 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
  17. data/lib/samagotchi/bridge.rb +122 -7
  18. data/lib/samagotchi/bridge_client.rb +33 -6
  19. data/lib/samagotchi/bundle_command.rb +13 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  21. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  22. data/lib/samagotchi/bundles/system/self_map.md +3 -1
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  24. data/lib/samagotchi/engine.rb +82 -18
  25. data/lib/samagotchi/kernel_loop.rb +16 -5
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -8
  27. data/lib/samagotchi/llm/model_result.rb +12 -7
  28. data/lib/samagotchi/log_subscriber.rb +1 -0
  29. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  30. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  31. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  32. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  33. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  34. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  36. data/lib/samagotchi/memory_bundle/trash.rb +57 -0
  37. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  38. data/lib/samagotchi/parent_report.rb +8 -0
  39. data/lib/samagotchi/plugin/context.rb +14 -2
  40. data/lib/samagotchi/question_desk.rb +97 -18
  41. data/lib/samagotchi/relay.rb +88 -0
  42. data/lib/samagotchi/relay_desk.rb +73 -0
  43. data/lib/samagotchi/relay_verifier.rb +113 -0
  44. data/lib/samagotchi/relay_watcher.rb +46 -0
  45. data/lib/samagotchi/reply_wait.rb +10 -1
  46. data/lib/samagotchi/self_report.rb +49 -3
  47. data/lib/samagotchi/send_command.rb +45 -6
  48. data/lib/samagotchi/session.rb +15 -2
  49. data/lib/samagotchi/session_manager.rb +3 -2
  50. data/lib/samagotchi/session_metrics.rb +154 -48
  51. data/lib/samagotchi/sessions_command.rb +10 -4
  52. data/lib/samagotchi/system_prompt.rb +45 -4
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
  55. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
  56. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  57. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  58. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  59. data/lib/samagotchi/terminal_ui.rb +24 -0
  60. data/lib/samagotchi/tool_declarations.rb +2 -2
  61. data/lib/samagotchi/tools/builtins.rb +11 -3
  62. data/lib/samagotchi/tools/delegate_relay.rb +220 -0
  63. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  64. data/lib/samagotchi/tools/peers.rb +7 -4
  65. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  66. data/lib/samagotchi/turn_note.rb +30 -2
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +22 -1
  69. data/lib/samagotchi/web/public/app.js +60 -33
  70. data/lib/samagotchi/web/public/index.html +7 -0
  71. data/lib/samagotchi/web/public/notify.js +22 -5
  72. data/lib/samagotchi/web/public/question_card.js +45 -3
  73. data/lib/samagotchi/web/public/question_cards.js +49 -1
  74. data/lib/samagotchi/web/public/sessions_list.js +8 -1
  75. data/lib/samagotchi/web/public/stage_model.js +8 -3
  76. data/lib/samagotchi/web/public/stage_view.js +20 -3
  77. data/lib/samagotchi/web/public/strip.js +11 -0
  78. data/lib/samagotchi/web/public/timing.js +37 -15
  79. data/lib/samagotchi/web/public/turn_events.js +22 -3
  80. data/lib/samagotchi/web/public/turn_view.js +34 -12
  81. data/lib/samagotchi/worker.rb +28 -2
  82. data/lib/samagotchi/worker_sidecar.rb +4 -1
  83. metadata +8 -1
@@ -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,
@@ -83,6 +92,23 @@ module Samagotchi
83
92
  nil
84
93
  end
85
94
 
95
+ # One turn's tool records from the session's saved analytics.json, in
96
+ # call order (iteration, then call_index), string-keyed as saved: what a
97
+ # join replays the turn's tool rows' durations from. Empty without a turn
98
+ # id (an older session's prompt), the file, or records.
99
+ # @param session_dir [String]
100
+ # @param turn_id [String, nil]
101
+ # @return [Array<Hash>]
102
+ def self.saved_tool_records(session_dir, turn_id)
103
+ return [] if turn_id.to_s.empty?
104
+
105
+ records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["tool_records"]
106
+ Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
107
+ .sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
108
+ rescue JSON::ParserError, SystemCallError, TypeError
109
+ []
110
+ end
111
+
86
112
  # "ctx 12%" for the session lists, "" when unknown.
87
113
  # @param pct [Float, nil]
88
114
  # @return [String]
@@ -101,6 +127,11 @@ module Samagotchi
101
127
  @turn = nil
102
128
  @turn_records = []
103
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
104
135
  end
105
136
 
106
137
  # /model: the window and prompt profile a turn reported were the old
@@ -196,51 +227,31 @@ module Samagotchi
196
227
  # The session totals are sums over the turn records (the ones loaded from
197
228
  # disk and this process's), with the running turn's calls, iterations and
198
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.
199
234
  # @return [Hash] the current summary snapshot
200
235
  def snapshot
201
- @mutex.synchronize do
202
- records = @turn_records
203
- turn = @turn
204
- in_flight = turn ? turn.tool_calls_by_id.values : []
205
- tools = @tool_records + in_flight
206
- {
207
- session_id: @session_id,
208
- turns: records.size + (turn ? 1 : 0),
209
- cancellations: records.count { |record| record[:status] == "canceled" },
210
- tokens: {
211
- prompt_sum: sum(records, :prompt_tokens_sum) + (turn&.prompt_sum || 0),
212
- completion_sum: sum(records, :completion_tokens) + (turn&.completion_sum || 0),
213
- source: combined_source(records.map { |record| record[:token_source] } + (turn&.token_sources || []))
214
- },
215
- context: context_block(records),
216
- profile: @profile,
217
- profile_source: @profile_source,
218
- served_model: @served_model,
219
- served_model_for: @served_model_for,
220
- tool_calls_total: tools.size,
221
- tool_calls_by_tool: tools.map { |tool| tool[:tool].to_s }.reject(&:empty?).tally,
222
- tool_errors: @tool_records.count { |tool| tool[:status] == "error" },
223
- iterations_total: sum(records, :iterations) + (turn&.iteration_count || 0),
224
- gen_latency_ms: (sum(records, :gen_ms) + (turn&.gen_latency_accum || 0)).round,
225
- retries: sum(records, :retries) + (turn&.retries || 0),
226
- started_at: @started_at,
227
- last_activity_at: @last_activity_at,
228
- session_duration_ms: elapsed_ms(@session_started_monotonic),
229
- turn_records: @turn_records.map(&:dup),
230
- tool_records: @tool_records.map(&:dup),
231
- active_turn: active_turn_snapshot,
232
- active_tools: active_tool_snapshots
233
- }
234
- 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 } }
235
244
  end
236
245
 
237
- # Persist the summary snapshot to a sibling analytics.json in the session
238
- # directory. Atomic write; failures are swallowed (analytics must never
239
- # 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).
240
249
  # @param state_dir [String, nil]
241
250
  # @return [Boolean] true on success
242
251
  def persist(state_dir: nil)
243
- 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
244
255
  return false if sid.nil? || sid.to_s.empty?
245
256
 
246
257
  # Omitting state_dir lets Session.session_dir fall back to the default
@@ -248,7 +259,8 @@ module Samagotchi
248
259
  # File.join.
249
260
  FileUtils.mkdir_p(dir = session_dir(sid, state_dir || @state_dir))
250
261
  path = File.join(dir, "analytics.json")
251
- 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 }
252
264
  true
253
265
  rescue StandardError
254
266
  false
@@ -256,6 +268,58 @@ module Samagotchi
256
268
 
257
269
  private
258
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
+
259
323
  def begin_turn(event)
260
324
  @mutex.synchronize do
261
325
  unless @session_id || event[:session_id].to_s.empty?
@@ -311,6 +375,7 @@ module Samagotchi
311
375
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
312
376
  record.merge!(turn_token_fields(@turn))
313
377
  @turn_records << record
378
+ count_turn(record)
314
379
  end
315
380
  @turn = nil
316
381
  end
@@ -422,7 +487,7 @@ module Samagotchi
422
487
  return unless active
423
488
 
424
489
  finished_at = now
425
- @tool_records << active.slice(
490
+ record = active.slice(
426
491
  :id, :turn_id, :iteration, :call_index, :tool, :started_at
427
492
  ).merge(
428
493
  status: status.empty? ? "ok" : status,
@@ -430,6 +495,8 @@ module Samagotchi
430
495
  # Less a guardrail approval wait (ToolRunner's waited_ms).
431
496
  duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
432
497
  )
498
+ @tool_records << record
499
+ count_tool(record)
433
500
  end
434
501
  end
435
502
 
@@ -531,8 +598,15 @@ module Samagotchi
531
598
  prior = JSON.parse(File.read(path))
532
599
  return unless prior.is_a?(Hash)
533
600
 
534
- @turn_records = loaded_records(prior["turn_records"]) + @turn_records
601
+ loaded_turns = loaded_records(prior["turn_records"])
602
+ @turn_records = loaded_turns + @turn_records
535
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) }
536
610
  @started_at = earliest_timestamp(prior["started_at"], @started_at)
537
611
  @last_activity_at ||= prior["last_activity_at"]
538
612
  # The window last seen, until this process's first generation reports.
@@ -562,8 +636,40 @@ module Samagotchi
562
636
  }
563
637
  end
564
638
 
565
- def sum(records, key)
566
- 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
567
673
  end
568
674
 
569
675
  # server, estimate, or mixed when both kinds of counts (or a turn that
@@ -148,15 +148,17 @@ module Samagotchi
148
148
  # ctx_pct: how full the context was after the last turn, or nil
149
149
  # waiting: the kind of question it waits on (question, approval,
150
150
  # hook: chi answer or the web answers it), or nil; waiting_id: its
151
- # id, for chi answer --question
151
+ # id, for chi answer --question; relayed_to: the parent session (short
152
+ # id) whose card it also waits in (the approval relay), or nil
152
153
  keys = %i[id short_id desc cwd project updated_at live busy owner recap parent_id archived scratch ctx_pct waiting
153
- waiting_id]
154
+ waiting_id relayed_to]
154
155
  @stdout.puts JSON.generate(summaries.map { |summary| summary.slice(*keys) })
155
156
  when "tsv"
156
157
  summaries.each { |summary| @stdout.puts "#{summary[:id]}\t#{summary[:desc]}" }
157
158
  else
158
159
  summaries.each do |summary|
159
- state = if summary[:waiting] then "waiting"
160
+ state = if summary[:relayed_to] then "waiting (in parent #{summary[:relayed_to]})"
161
+ elsif summary[:waiting] then "waiting"
160
162
  elsif summary[:busy] then "running"
161
163
  else summary[:live] ? "live" : summary[:status].to_s
162
164
  end
@@ -206,7 +208,11 @@ module Samagotchi
206
208
  )
207
209
  # A question waits for an answer (chi answer, the web, chi --attach).
208
210
  live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
209
- status = s.waiting_question(live: !!live) ? "waiting" : s.status
211
+ waiting = s.waiting_question(live: !!live)
212
+ status = if waiting&.dig(:relayed_to) then "waiting (in parent #{waiting[:relayed_to]})"
213
+ elsif waiting then "waiting"
214
+ else s.status
215
+ end
210
216
  "#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
211
217
  end
212
218
  if project
@@ -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.
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  # @param muted_memories [Array<String>] --mute: hidden from a new session
39
39
  # @return [Symbol] :detached, :closed when the worker went away,
40
40
  # :failed when the --model switch didn't go through, or (input from a
41
- # pipe) :turn_failed / :unanswered (AttachedLoop#run)
41
+ # pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
42
42
  def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
43
43
  memories: [], muted_memories: [])
44
44
  client = connect(attach: attach, shared: shared, resume: resume, model: model,
@@ -56,6 +56,7 @@ module Samagotchi
56
56
  close_surface(surface)
57
57
  end
58
58
  report_unanswered(attached.unanswered, session_id: client.session_id) if ended == :unanswered && attached.unanswered
59
+ report_ended(ended)
59
60
  ended
60
61
  end
61
62
 
@@ -68,9 +69,17 @@ module Samagotchi
68
69
  err.flush
69
70
  end
70
71
 
72
+ # A turn of ours that ended with no answer (input from a pipe): one
73
+ # line on stderr, as the in-process `chi -p --non-interactive` says
74
+ # it, so a script doesn't take the silence for an answer (exit 1).
75
+ def report_ended(ended, err: $stderr)
76
+ err.puts(TerminalUI::EMPTY_ANSWER_ERROR) if ended == :empty_answer
77
+ end
78
+
71
79
  # chi's exit status after #run: 0 detached, 3 a question left waiting
72
80
  # for an answer (input from a pipe ran out; chi --attach or chi answer
73
- # answers it), 1 anything else (a failed turn, the worker gone).
81
+ # answers it), 1 anything else (a failed turn, an empty answer, the
82
+ # worker gone).
74
83
  # @param ended [Symbol] what #run returned
75
84
  # @return [Integer]
76
85
  def exit_status(ended)
@@ -20,6 +20,7 @@ require_relative "../model_profile"
20
20
  require_relative "../output_formatter"
21
21
  require_relative "../session_commands"
22
22
  require_relative "../session_manager"
23
+ require_relative "../session_metrics"
23
24
  require_relative "../guardrails/parent_approvals"
24
25
  require_relative "../tool_activity"
25
26
  require_relative "../web/message_parts"
@@ -143,8 +144,9 @@ module Samagotchi
143
144
  # asks it to exit too, and it does unless something still needs it.
144
145
  # @return [Symbol] :detached, :closed when the worker went away,
145
146
  # :failed when the first command (--model) didn't go through, or
146
- # (wait_at_eof) :turn_failed when one of this run's prompts failed and
147
- # :unanswered when a question came after the input ended
147
+ # (wait_at_eof) :turn_failed when one of this run's prompts failed,
148
+ # :empty_answer when one ended with no answer, and :unanswered when
149
+ # a question came after the input ended
148
150
  # @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
149
151
  # Interrupt = Ctrl-C); defaults to Reline
150
152
  def run(input: nil)
@@ -259,7 +261,9 @@ module Samagotchi
259
261
  ask(event[:pending_question])
260
262
  when :question_answered then question_answered(event)
261
263
  when :question_cancelled
262
- close_question("(question cancelled)") if @question
264
+ close_question(@question.closed_text(event[:reason])) if @question
265
+ # Relayed to the parent's card, or no longer: the widget says so.
266
+ when :question_relay then question_relayed(event)
263
267
  # Written while the session sat idle: shown at the open prompt. One
264
268
  # collected just as a turn started describes the chat before it.
265
269
  when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
@@ -306,7 +310,13 @@ module Samagotchi
306
310
  return detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if line.nil?
307
311
 
308
312
  text = line.strip
309
- 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
310
320
 
311
321
  # The terminal's own commands (SessionCommands registers them, the
312
322
  # REPL reads the same words). Before the continue offer: the
@@ -445,7 +455,12 @@ module Samagotchi
445
455
  # too, so nothing waits here.
446
456
  def send_command(line)
447
457
  reply = @client.post_command(line: line, client_id: @client_id)
448
- 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
449
464
  return @screen.commit(stale_worker("run commands")) if reply.status == 404
450
465
  return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
451
466
 
@@ -469,6 +484,7 @@ module Samagotchi
469
484
  end
470
485
 
471
486
  def command_ran(event)
487
+ @open_ids.delete(event[:command_id])
472
488
  @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
473
489
  output = event[:output].to_s
474
490
  if output.empty?
@@ -569,6 +585,7 @@ module Samagotchi
569
585
  id = (event[:origin] || {})[:enqueued_id]
570
586
  ours = @open_ids.include?(id) || !@merged_ids.empty?
571
587
  @own_failed = true if ours && event[:type] == :turn_failed
588
+ @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
572
589
  @open_ids.delete(id)
573
590
  @open_ids.subtract(@merged_ids)
574
591
  @merged_ids.clear
@@ -591,7 +608,9 @@ module Samagotchi
591
608
  # The input ended and nothing of ours is left to wait for.
592
609
  def end_of_input
593
610
  detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
594
- @own_failed ? :turn_failed : :detached
611
+ return :turn_failed if @own_failed
612
+
613
+ @own_empty ? :empty_answer : :detached
595
614
  end
596
615
 
597
616
  def unanswered_question(pending)
@@ -757,6 +776,13 @@ module Samagotchi
757
776
  end
758
777
  end
759
778
 
779
+ def question_relayed(event)
780
+ return unless @question && @question.id == event[:id].to_s
781
+
782
+ @question.relayed_to = event[:relayed_to]
783
+ @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
784
+ end
785
+
760
786
  def question_answered(event)
761
787
  return unless @question
762
788
  return close_question(nil) if @answered_ids.include?(event[:id])
@@ -925,7 +951,8 @@ module Samagotchi
925
951
  return if text.empty?
926
952
 
927
953
  @screen.commit("#{paint(PROMPT, 92)}#{text}")
928
- 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)
929
956
  end
930
957
 
931
958
  def render_join_header(messages)
@@ -939,39 +966,76 @@ module Samagotchi
939
966
 
940
967
  @screen.commit(prompt_line(nil, exchange[last_user][:content]))
941
968
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
942
- render_join_steps(messages, messages.index { |m| m.equal?(exchange[last_user]) })
943
- # The saved answer is raw: the latest keeps its thinking, and one may
944
- # be only a tool call.
945
- answer = exchange[(last_user + 1)..].reverse_each
946
- .map { |m| OutputFormatter.strip_markup(m[:content]) }
947
- .find { |text| !text.empty? }
948
- @screen.commit(last_lines(answer)) if answer
969
+ from = messages.index { |m| m.equal?(exchange[last_user]) }
970
+ render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
971
+ # A turn that ended with no answer: its notice, as live.
972
+ empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
973
+ if empty
974
+ @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
975
+ else
976
+ # The saved answer is raw: the latest keeps its thinking, and one may
977
+ # be only a tool call.
978
+ answer = exchange[(last_user + 1)..].reverse_each
979
+ .map { |m| OutputFormatter.strip_markup(m[:content]) }
980
+ .find { |text| !text.empty? }
981
+ @screen.commit(last_lines(answer)) if answer
982
+ end
949
983
  render_join_notes(messages, after_exchange: true)
950
984
  end
951
985
 
952
986
  # What the last exchange did, as the live turn drew it: each saved
953
- # call's tool row (action, params, status from its result; no
954
- # duration, which the session doesn't keep) and a plugin's steer
955
- # where it came. The step texts stay out, as live (the ticker's).
956
- def render_join_steps(messages, from)
987
+ # call's tool row (action, params, status from its result, and its
988
+ # duration from +durations+, the turn's saved tool records) and a
989
+ # plugin's steer where it came. The step texts stay out, as live (the
990
+ # ticker's).
991
+ def render_join_steps(messages, from, durations: [])
992
+ records = durations.dup
957
993
  messages.each_with_index.drop(from + 1).each do |m, i|
958
994
  if Steer.steer?(m)
959
995
  @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
960
996
  elsif %w[model assistant].include?(m[:role].to_s)
961
997
  responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
962
- Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each { |part| @screen.commit(join_tool_line(part)) }
998
+ Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
999
+ @screen.commit(join_tool_line(part, duration_ms: join_duration(records, part)))
1000
+ end
963
1001
  end
964
1002
  end
965
1003
  end
966
1004
 
1005
+ # The prompt's turn's saved tool records (SessionMetrics, in this
1006
+ # machine's state dir, as #attach_images): none for a prompt with no
1007
+ # turn id (an older session) or a session with no analytics.
1008
+ def join_tool_records(prompt)
1009
+ SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
1010
+ rescue Session::InvalidId
1011
+ []
1012
+ end
1013
+
1014
+ # The duration of +part+'s call: the next record of its tool, in call
1015
+ # order, consumed with the ones it skipped. By tool and order, not by
1016
+ # iteration: an empty retry spends an iteration that left no message.
1017
+ # nil (no duration shown) for a call that left none: it never
1018
+ # finished, or a worker before turn ids saved it.
1019
+ def join_duration(records, part)
1020
+ return nil if part[:output].nil?
1021
+
1022
+ at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
1023
+ return nil unless at
1024
+
1025
+ duration = records[at]["duration_ms"]
1026
+ records.shift(at + 1)
1027
+ duration.is_a?(Numeric) ? duration : nil
1028
+ end
1029
+
967
1030
  # A saved call's row; "no result" for a call the turn never answered
968
1031
  # (it ended first).
969
- def join_tool_line(part)
1032
+ def join_tool_line(part, duration_ms: nil)
970
1033
  output = part[:output]
971
1034
  status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
972
1035
  activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
973
1036
  params: part[:params], status: status }
974
- "#{format_tool_activity_line(activity)}#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1037
+ "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1038
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
975
1039
  end
976
1040
 
977
1041
  # The context notes that came since the last prompt (all of them in a