samagotchi 0.16.0 → 0.18.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +38 -2
  5. data/docs/cli.md +37 -9
  6. data/docs/configuration.md +8 -6
  7. data/docs/guardrails.md +51 -8
  8. data/docs/hooks.md +4 -1
  9. data/docs/internals/background-tasks.md +1 -0
  10. data/docs/memory.md +41 -0
  11. data/docs/plugins.md +5 -2
  12. data/docs/sessions.md +8 -6
  13. data/docs/sub-agent.md +43 -4
  14. data/docs/testing.md +47 -0
  15. data/lib/samagotchi/answer_command.rb +23 -2
  16. data/lib/samagotchi/answer_tail.rb +52 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +20 -5
  18. data/lib/samagotchi/bridge.rb +86 -14
  19. data/lib/samagotchi/bridge_client.rb +18 -3
  20. data/lib/samagotchi/bundle_command.rb +121 -4
  21. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +18 -3
  22. data/lib/samagotchi/bundles/guardrails/manifest.yml +4 -4
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  24. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  25. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  26. data/lib/samagotchi/bundles/system/self_map.md +1 -0
  27. data/lib/samagotchi/commands/registry.rb +28 -0
  28. data/lib/samagotchi/config.rb +7 -1
  29. data/lib/samagotchi/continue_offer.rb +177 -0
  30. data/lib/samagotchi/engine.rb +73 -19
  31. data/lib/samagotchi/guardrails/outside.rb +46 -0
  32. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -0
  33. data/lib/samagotchi/guardrails/parent_continue.rb +43 -0
  34. data/lib/samagotchi/guardrails/rules.rb +17 -5
  35. data/lib/samagotchi/guardrails/shell_git_dirs.rb +267 -0
  36. data/lib/samagotchi/guardrails/targets.rb +30 -13
  37. data/lib/samagotchi/guardrails.rb +2 -0
  38. data/lib/samagotchi/iteration_limit.rb +38 -0
  39. data/lib/samagotchi/kernel_loop.rb +2 -1
  40. data/lib/samagotchi/llm/backend.rb +2 -1
  41. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  42. data/lib/samagotchi/llm/native_backend.rb +2 -1
  43. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  44. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  45. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  46. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  47. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  49. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  50. data/lib/samagotchi/memory_bundle/trash.rb +136 -0
  51. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  52. data/lib/samagotchi/parent_report.rb +39 -9
  53. data/lib/samagotchi/plugin/context.rb +14 -2
  54. data/lib/samagotchi/question_desk.rb +205 -46
  55. data/lib/samagotchi/reply_wait.rb +10 -4
  56. data/lib/samagotchi/self_report.rb +49 -3
  57. data/lib/samagotchi/send_command.rb +46 -6
  58. data/lib/samagotchi/session.rb +3 -2
  59. data/lib/samagotchi/session_commands.rb +3 -2
  60. data/lib/samagotchi/session_manager.rb +4 -1
  61. data/lib/samagotchi/session_metrics.rb +137 -48
  62. data/lib/samagotchi/sessions_command.rb +3 -2
  63. data/lib/samagotchi/system_prompt.rb +46 -5
  64. data/lib/samagotchi/terminal_ui/attached_loop.rb +39 -6
  65. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  66. data/lib/samagotchi/terminal_ui/question_prompt.rb +34 -2
  67. data/lib/samagotchi/terminal_ui.rb +35 -2
  68. data/lib/samagotchi/tool_declarations.rb +2 -2
  69. data/lib/samagotchi/tools/builtins.rb +11 -3
  70. data/lib/samagotchi/tools/delegate_relay.rb +4 -0
  71. data/lib/samagotchi/tools/delegate_wait.rb +14 -5
  72. data/lib/samagotchi/tools/edit.rb +6 -1
  73. data/lib/samagotchi/tools/execute.rb +5 -4
  74. data/lib/samagotchi/tools/peers.rb +3 -2
  75. data/lib/samagotchi/tools/read.rb +2 -1
  76. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  77. data/lib/samagotchi/turn_flow.rb +8 -7
  78. data/lib/samagotchi/turn_note.rb +7 -0
  79. data/lib/samagotchi/version.rb +1 -1
  80. data/lib/samagotchi/web/app.rb +189 -43
  81. data/lib/samagotchi/web/public/app.js +157 -51
  82. data/lib/samagotchi/web/public/data.js +12 -13
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/notify.js +12 -5
  85. data/lib/samagotchi/web/public/question_card.js +28 -1
  86. data/lib/samagotchi/web/public/question_cards.js +74 -3
  87. data/lib/samagotchi/web/public/sessions_list.js +1 -0
  88. data/lib/samagotchi/web/public/stage_view.js +13 -1
  89. data/lib/samagotchi/web/public/strip.js +11 -0
  90. data/lib/samagotchi/web/public/timing.js +67 -12
  91. data/lib/samagotchi/web/public/turn_events.js +74 -3
  92. data/lib/samagotchi/web/public/update_notice.js +15 -0
  93. data/lib/samagotchi/worker.rb +58 -60
  94. data/lib/samagotchi/worker_sidecar.rb +7 -1
  95. metadata +10 -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,
@@ -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
@@ -191,11 +191,12 @@ module Samagotchi
191
191
  order ||= "desc"
192
192
  sessions = Samagotchi::SessionManager.list_sessions(sort: sort, order: order, limit: limit, project_root: project,
193
193
  include_archived: include_archived)
194
- # The saved recap's first sentence, else the last prompt, cut as before.
194
+ # The saved recap's first sentence, else the last prompt (or the first
195
+ # message, before any turn), cut as before.
195
196
  state_dir = Samagotchi::Session.default_state_dir
196
197
  list_text = lambda do |s|
197
198
  recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
198
- (recap || Samagotchi::SessionManager.one_line(s.last_prompt))[0, 60]
199
+ (recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
199
200
  end
200
201
  # A delegated session points at its parent: ↳ <parent's short id>.
201
202
  row = lambda do |s|
@@ -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)
@@ -359,7 +381,7 @@ module Samagotchi
359
381
  root = MemoryPaths.project_root(cwd)
360
382
  lines = ["Current working directory:", cwd]
361
383
  unless root == cwd
362
- lines << "Project root (project memories are shared by all worktrees and subdirectories of this repository):"
384
+ lines << "Project root (only where shared project memories come from; read, edit, run and commit in the current working directory above):"
363
385
  lines << root
364
386
  end
365
387
  home = Dir.home
@@ -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
@@ -336,6 +342,9 @@ module Samagotchi
336
342
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
337
343
  persist_recent_history(text) if PromptHistory.shell_line?(text)
338
344
  send_command(text)
345
+ elsif (hint = command_registry.unknown_command_hint(text))
346
+ # A typo of a command (/modle) is not a prompt: say so, send nothing.
347
+ @screen.commit(hint)
339
348
  else
340
349
  send_prompt(text)
341
350
  end
@@ -449,7 +458,12 @@ module Samagotchi
449
458
  # too, so nothing waits here.
450
459
  def send_command(line)
451
460
  reply = @client.post_command(line: line, client_id: @client_id)
452
- return if reply.status == 202
461
+ if reply.status == 202
462
+ # Input from a pipe ends once its output is in (#command_ran).
463
+ command_id = reply.json&.fetch("command_id", nil)
464
+ @open_ids << command_id if command_id && @wait_at_eof
465
+ return
466
+ end
453
467
  return @screen.commit(stale_worker("run commands")) if reply.status == 404
454
468
  return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
455
469
 
@@ -473,6 +487,7 @@ module Samagotchi
473
487
  end
474
488
 
475
489
  def command_ran(event)
490
+ @open_ids.delete(event[:command_id])
476
491
  @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
477
492
  output = event[:output].to_s
478
493
  if output.empty?
@@ -510,6 +525,9 @@ module Samagotchi
510
525
  # UI's answer shows as its command line (web> /continue yes) instead.
511
526
  def continue_resolved(event)
512
527
  @continue_offer = nil
528
+ # Asked as the step-limit question: its line says what became of it.
529
+ asked = @offer_asked
530
+ @offer_asked = false
513
531
  sync_continue_slot
514
532
  who = event[:client_id] ? CLIENT_LABELS.fetch(event[:client_id].to_s.split(":", 2).first, "another UI") : "another UI"
515
533
  outcome = if event[:decision] == "dropped"
@@ -518,14 +536,14 @@ module Samagotchi
518
536
  @continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
519
537
  end
520
538
  @continue_answer = nil
521
- @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome
539
+ @screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
522
540
  sync_prompt
523
541
  end
524
542
 
525
543
  # The notes slot shows the offer's choices while it waits (a question
526
544
  # has the slot while it is open).
527
545
  def sync_continue_slot
528
- return if @question
546
+ return if @question || @offer_asked
529
547
 
530
548
  if @continue_offer
531
549
  @screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
@@ -680,6 +698,9 @@ module Samagotchi
680
698
  @set_aside = @reader&.typed_text unless @question
681
699
  @question = QuestionPrompt.new(pending)
682
700
  @question_pending = pending
701
+ # The step-limit question stands for the continue offer's own slot
702
+ # until the offer is resolved.
703
+ @offer_asked = true if @question.continue?
683
704
  # An edit's diff goes above, into the scrollback, once per question
684
705
  # (a join gets it from the snapshot and may get the event too).
685
706
  unless @previewed_id == @question.id
@@ -693,7 +714,9 @@ module Samagotchi
693
714
  end
694
715
 
695
716
  def answer_question(text)
696
- return dismiss_question if text.empty?
717
+ # Enter alone at the step-limit question continues (it can't be
718
+ # dismissed), as at the continue prompt.
719
+ return dismiss_question if text.empty? && !@question.continue?
697
720
 
698
721
  answer = @question.parse(text)
699
722
  @screen.commit(answer.note) if answer.note
@@ -881,6 +904,8 @@ module Samagotchi
881
904
  sync_continue_slot
882
905
  end
883
906
  render_current_turn(snapshot[:current_turn])
907
+ # A question between turns (the step-limit one) belongs to no turn.
908
+ ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
884
909
  render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
885
910
  Array(snapshot[:queued]).each do |entry|
886
911
  next if own?(entry[:client_id])
@@ -939,7 +964,15 @@ module Samagotchi
939
964
  return if text.empty?
940
965
 
941
966
  @screen.commit("#{paint(PROMPT, 92)}#{text}")
942
- send_prompt(text)
967
+ # As if typed: a session command runs as the command; a typo of one
968
+ # gets the hint, not a turn.
969
+ if command_registry.command?(text)
970
+ send_command(text)
971
+ elsif (hint = command_registry.unknown_command_hint(text))
972
+ @screen.commit(hint)
973
+ else
974
+ send_prompt(text)
975
+ end
943
976
  end
944
977
 
945
978
  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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "question_slot"
4
+ require_relative "../turn_flow"
4
5
 
5
6
  module Samagotchi
6
7
  class TerminalUI
@@ -12,7 +13,10 @@ module Samagotchi
12
13
  # Bridge). An approval (kind "approval") takes only a number, an exact
13
14
  # label, y (the first option, Allow once) or n (the last, Deny), plus
14
15
  # an optional "; reason"; a substring would pick the wrong one ("y" is
15
- # in "Deny").
16
+ # in "Deny"). The step-limit question (kind "continue") reads the
17
+ # continue words (TurnFlow.continue_decision): Enter, yes, y or
18
+ # /continue continue; no stops; "no, why" stops with the reason; 1 and
19
+ # 2 pick the options.
16
20
  class QuestionPrompt
17
21
  DIFF_CODES = { "+" => 32, "-" => 31, "@" => 2, "\\" => 2 }.freeze
18
22
 
@@ -51,11 +55,13 @@ module Samagotchi
51
55
  @multi = !!field.(:multi_select)
52
56
  @free = !!field.(:allow_freeform)
53
57
  @approval = field.(:kind).to_s == "approval"
58
+ @continue = field.(:kind).to_s == "continue"
54
59
  end
55
60
 
56
61
  def multi? = @multi
57
62
  def free? = @free
58
63
  def approval? = @approval
64
+ def continue? = @continue
59
65
 
60
66
  # The relay mark (QuestionDesk#annotate's relayed_to: a Hash, or the
61
67
  # short id), or nil when cleared.
@@ -77,6 +83,7 @@ module Samagotchi
77
83
  # What a question closed with no answer here says: a relayed approval
78
84
  # names where it went; else "(question cancelled)".
79
85
  def closed_text(reason)
86
+ return CONTINUE_CLOSED.fetch(reason.to_s, "(question cancelled)") if continue?
80
87
  return "(question cancelled)" unless @delegate
81
88
 
82
89
  case reason.to_s
@@ -98,6 +105,10 @@ module Samagotchi
98
105
  hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint, note: note)
99
106
  end
100
107
 
108
+ # Why a step-limit question closed unanswered here.
109
+ CONTINUE_CLOSED = { "dropped" => "(dropped: a new prompt came)", "answered" => "(answered with /continue)",
110
+ "superseded" => "(set aside for another question)", "replaced" => "(asked again)" }.freeze
111
+
101
112
  # The most diff lines #preview_lines prints; the web shows them all.
102
113
  PREVIEW_LINES = 40
103
114
 
@@ -138,7 +149,7 @@ module Samagotchi
138
149
  # @return [String] "Banana", "Apple, Cherry; ripe ones", "Deny: use a PR"
139
150
  def answer_text(answer)
140
151
  picked = answer.selected.to_a
141
- if approval?
152
+ if approval? || continue?
142
153
  choice = picked.first || options.last
143
154
  return answer.freeform ? "#{choice}: #{answer.freeform}" : choice
144
155
  end
@@ -150,6 +161,7 @@ module Samagotchi
150
161
  # @return [Answer]
151
162
  def parse(raw)
152
163
  return parse_approval(raw) if approval?
164
+ return parse_continue(raw) if continue?
153
165
 
154
166
  raw = raw.to_s.strip
155
167
  # "1,3; my text": the first ';' separates the selection from freeform text.
@@ -178,6 +190,9 @@ module Samagotchi
178
190
  def mark = approval? ? "! " : "? "
179
191
 
180
192
  def slot_hint
193
+ if continue?
194
+ return "Enter or yes = #{options.first}; no = #{options.last}; no, <reason> = #{options.last} and tell the model why"
195
+ end
181
196
  if approval?
182
197
  return "1-#{options.size}, y = #{options.first}, n = #{options.last}; add '; reason' to tell the model why; " \
183
198
  "Enter alone denies"
@@ -189,6 +204,23 @@ module Samagotchi
189
204
  hint.join("; ")
190
205
  end
191
206
 
207
+ # The continue words, or an option's number. Empty is Continue (Enter
208
+ # alone, as at the REPL's continue prompt).
209
+ def parse_continue(raw)
210
+ text = raw.to_s.strip
211
+ if text.match?(/\A\d+\z/)
212
+ idx = text.to_i - 1
213
+ return Answer.new(selected: [options[idx]]) if idx.between?(0, options.size - 1)
214
+ end
215
+ decision, reason = TurnFlow.continue_decision(text)
216
+ case decision
217
+ when :resume then Answer.new(selected: [options.first])
218
+ when :abort then Answer.new(selected: [options.last])
219
+ when :abort_with_reason then Answer.new(selected: [options.last], freeform: reason)
220
+ else Answer.new(error: "Answer yes (Enter alone too), no, or no, <reason>.")
221
+ end
222
+ end
223
+
192
224
  def parse_approval(raw)
193
225
  sel_part, free_part = raw.to_s.strip.split(";", 2).map { |part| part.to_s.strip }
194
226
  free_part = nil if free_part.to_s.empty?