samagotchi 0.14.0 → 0.16.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/docs/architecture.md +10 -0
  4. data/docs/cli.md +3 -2
  5. data/docs/configuration.md +3 -1
  6. data/docs/guardrails.md +16 -2
  7. data/docs/plugins.md +3 -3
  8. data/docs/sessions.md +7 -2
  9. data/docs/sub-agent.md +15 -3
  10. data/lib/samagotchi/answer_display.rb +6 -2
  11. data/lib/samagotchi/bridge/card_store.rb +16 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +6 -0
  13. data/lib/samagotchi/bridge.rb +84 -0
  14. data/lib/samagotchi/bridge_client.rb +33 -6
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  17. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  18. data/lib/samagotchi/engine.rb +58 -17
  19. data/lib/samagotchi/kernel_loop.rb +16 -5
  20. data/lib/samagotchi/llm/chat_loop.rb +17 -9
  21. data/lib/samagotchi/llm/model_result.rb +12 -7
  22. data/lib/samagotchi/log_subscriber.rb +1 -0
  23. data/lib/samagotchi/parent_report.rb +8 -0
  24. data/lib/samagotchi/plugin_session_state.rb +30 -0
  25. data/lib/samagotchi/question_desk.rb +97 -18
  26. data/lib/samagotchi/relay.rb +88 -0
  27. data/lib/samagotchi/relay_desk.rb +73 -0
  28. data/lib/samagotchi/relay_verifier.rb +113 -0
  29. data/lib/samagotchi/relay_watcher.rb +46 -0
  30. data/lib/samagotchi/reply_wait.rb +10 -1
  31. data/lib/samagotchi/self_report.rb +24 -4
  32. data/lib/samagotchi/session.rb +15 -2
  33. data/lib/samagotchi/session_manager.rb +5 -2
  34. data/lib/samagotchi/session_metrics.rb +18 -1
  35. data/lib/samagotchi/session_retention.rb +6 -1
  36. data/lib/samagotchi/sessions_command.rb +10 -4
  37. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  38. data/lib/samagotchi/terminal_ui/attached_loop.rb +88 -12
  39. data/lib/samagotchi/terminal_ui/event_renderer.rb +6 -1
  40. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  41. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  42. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  43. data/lib/samagotchi/terminal_ui.rb +11 -0
  44. data/lib/samagotchi/tool_declarations.rb +2 -2
  45. data/lib/samagotchi/tools/delegate_relay.rb +216 -0
  46. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  47. data/lib/samagotchi/tools/peers.rb +6 -4
  48. data/lib/samagotchi/turn_note.rb +30 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +24 -0
  51. data/lib/samagotchi/web/public/app.js +32 -11
  52. data/lib/samagotchi/web/public/index.html +9 -0
  53. data/lib/samagotchi/web/public/notify.js +18 -5
  54. data/lib/samagotchi/web/public/question_card.js +45 -3
  55. data/lib/samagotchi/web/public/question_cards.js +49 -1
  56. data/lib/samagotchi/web/public/sessions_list.js +16 -1
  57. data/lib/samagotchi/web/public/stage_model.js +8 -3
  58. data/lib/samagotchi/web/public/stage_view.js +7 -2
  59. data/lib/samagotchi/web/public/strip.js +8 -1
  60. data/lib/samagotchi/web/public/timing.js +22 -5
  61. data/lib/samagotchi/web/public/turn_events.js +9 -0
  62. data/lib/samagotchi/web/public/turn_view.js +38 -13
  63. metadata +7 -1
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Samagotchi
6
+ # A parent worker's relays of its delegates' approvals: relay id → the
7
+ # child and its question, and the answer this worker's own question flow
8
+ # took from a UI. A child believes a relayed answer only when it asked
9
+ # this table for it, through the parent's Bridge (POST relay/status).
10
+ #
11
+ # Memory only: never in the session file, the event log, a snapshot, a
12
+ # log line or the environment, so nothing but this worker's question flow
13
+ # writes it. Entries are dropped KEEP_SECONDS after they settle.
14
+ class RelayDesk
15
+ KEEP_SECONDS = 600
16
+
17
+ # @param clock [#call] monotonic seconds (specs)
18
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
19
+ @clock = clock
20
+ @mutex = Mutex.new
21
+ @relays = {}
22
+ end
23
+
24
+ # @return [String] the new relay's id (a UUID: an id, not a secret)
25
+ def open(child_id:, child_question_id:)
26
+ id = SecureRandom.uuid
27
+ @mutex.synchronize do
28
+ prune
29
+ @relays[id] = { child_id: child_id.to_s, child_question_id: child_question_id.to_s, state: "open",
30
+ answer: nil, by: nil, settled_at: nil }
31
+ end
32
+ id
33
+ end
34
+
35
+ # The parent's user (or a parent agent) answered the relay card.
36
+ # @param by [String] "user" or "parent_agent"
37
+ # @return [Boolean] false when the relay isn't open (first answer wins)
38
+ def record(id, selected_indices: [], freeform: nil, dismissed: false, by:)
39
+ @mutex.synchronize do
40
+ relay = @relays[id.to_s]
41
+ next false unless relay && relay[:state] == "open"
42
+
43
+ relay.merge!(state: "answered", by: by.to_s, settled_at: @clock.call,
44
+ answer: { selected_indices: Array(selected_indices), freeform: freeform, dismissed: !!dismissed })
45
+ true
46
+ end
47
+ end
48
+
49
+ # The relay ended with no answer (a Stop, the child gone or answered).
50
+ def close(id)
51
+ @mutex.synchronize do
52
+ relay = @relays[id.to_s]
53
+ relay&.merge!(state: "closed", settled_at: @clock.call) if relay && relay[:state] == "open"
54
+ end
55
+ nil
56
+ end
57
+
58
+ # @return [Hash, nil] {child_id:, child_question_id:, state:, answer:, by:}
59
+ def status(id)
60
+ @mutex.synchronize do
61
+ prune
62
+ @relays[id.to_s]&.except(:settled_at)&.then { |relay| Marshal.load(Marshal.dump(relay)) }
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def prune
69
+ now = @clock.call
70
+ @relays.delete_if { |_, relay| relay[:settled_at] && now - relay[:settled_at] > KEEP_SECONDS }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "bridge_client"
5
+ require_relative "worker_sidecar"
6
+ require_relative "question_desk"
7
+ require_relative "guardrails/parent_approvals"
8
+ require_relative "log"
9
+
10
+ module Samagotchi
11
+ # A child worker taking its parent's answer to a relayed approval. The
12
+ # POST that says "answered" carries no answer and no authority: this
13
+ # worker asks its parent session's live Bridge what the parent's relay
14
+ # holds (RelayDesk, which only the parent's question flow writes), and
15
+ # answers its own question with that, only when it is about this session
16
+ # and the question pending now, and is answered.
17
+ class RelayVerifier
18
+ # The client id a relayed answer is recorded with: relay:<parent8>.
19
+ CLIENT_PREFIX = "relay"
20
+
21
+ # @param engine [Engine] this (the child's) worker's Engine
22
+ def initialize(engine:, state_dir:, session_id:)
23
+ @engine = engine
24
+ @state_dir = state_dir
25
+ @session_id = session_id
26
+ end
27
+
28
+ # @return [Array(Hash, Integer, Hash)] headers, status, body: 200
29
+ # answered, 409 no longer pending, 403 refused (a parent agent's allow
30
+ # beyond this worker's guardrails.parent_approvals), 422
31
+ # relay_unverified (logged; the question stays open)
32
+ def answer(relay_id:, question_id:)
33
+ parent_id = own_parent_id or return unverified("this session has no parent", relay_id)
34
+ relay = parent_relay(parent_id, relay_id) or return unverified("the parent has no such relay", relay_id)
35
+ return unverified("the relay is about another session", relay_id) unless relay["child_id"] == @session_id
36
+ return unverified("the relay is about another question", relay_id) unless relay["child_question_id"] == question_id
37
+ return unverified("the relay is #{relay["state"]}, not answered", relay_id) unless relay["state"] == "answered"
38
+
39
+ apply(relay, parent_id: parent_id, question_id: question_id, relay_id: relay_id)
40
+ end
41
+
42
+ private
43
+
44
+ def apply(relay, parent_id:, question_id:, relay_id:)
45
+ answer = relay["answer"].is_a?(Hash) ? relay["answer"] : {}
46
+ parent_agent = relay["by"] != "user"
47
+ client_id = "#{CLIENT_PREFIX}:#{parent_id[0, 8]}"
48
+ if answer["dismissed"]
49
+ return not_pending(question_id) unless @engine.cancel_question("dismissed", id: question_id)
50
+
51
+ log("relay_answered", relay_id, id: question_id, dismissed: true, by: relay["by"])
52
+ return [{}, 200, { status: "dismissed", question_id: question_id }]
53
+ end
54
+
55
+ selected = labels(answer["selected_indices"], question_id) or return unverified("the answer's option is out of range", relay_id)
56
+ result = @engine.answer_question(id: question_id, selected: selected, freeform: answer["freeform"],
57
+ client_id: client_id, parent_agent: parent_agent)
58
+ log("relay_answered", relay_id, id: question_id, by: relay["by"])
59
+ [{}, 200, { status: "answered", question_id: question_id, answer: result }]
60
+ rescue QuestionDesk::NotPending
61
+ not_pending(question_id)
62
+ rescue QuestionDesk::Refused => e
63
+ log("relay_refused", relay_id, id: question_id, reason: e.reason)
64
+ [{}, 403, { error: "parent_approval_refused", reason: e.reason.to_s,
65
+ detail: Guardrails::ParentApprovals.message(e.reason) }]
66
+ rescue ArgumentError => e
67
+ unverified("the answer doesn't fit the question: #{e.message}", relay_id)
68
+ end
69
+
70
+ # The parent's option indices as this question's own labels (the
71
+ # parent's are relabelled; the order is the same). nil when one is out
72
+ # of range; [] for a freeform-only deny.
73
+ def labels(indices, question_id)
74
+ pending = @engine.pending_question
75
+ return [] unless pending && pending[:id].to_s == question_id
76
+
77
+ options = Array(pending[:options])
78
+ indices = Array(indices)
79
+ return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
80
+
81
+ indices.map { |index| options[index] }
82
+ end
83
+
84
+ def own_parent_id
85
+ Session.load(@session_id, state_dir: @state_dir).parent_id
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+
90
+ # @return [Hash, nil] what the parent's live Bridge says about the relay
91
+ def parent_relay(parent_id, relay_id)
92
+ dir = Session.session_dir(parent_id, state_dir: @state_dir)
93
+ client = BridgeClient.discover(parent_id, session_dir: dir) or return nil
94
+ response = client.relay_status(relay_id)
95
+ response.ok? ? response.json : nil
96
+ rescue SystemCallError, IOError
97
+ nil
98
+ end
99
+
100
+ def not_pending(question_id)
101
+ [{}, 409, { error: "question_not_pending", detail: "no pending question #{question_id}" }]
102
+ end
103
+
104
+ def unverified(why, relay_id)
105
+ log("relay_unverified", relay_id, why: why)
106
+ [{}, 422, { error: "relay_unverified", detail: why }]
107
+ end
108
+
109
+ def log(event, relay_id, **fields)
110
+ Log.info(:bridge, event, sid: @session_id, relay: relay_id.to_s[0, 8], **fields)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "worker_sidecar"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # A child's look at its parent while its question is relayed there: when
8
+ # the parent's worker is gone, its relay card and RelayDesk went with it,
9
+ # so the question's relay mark is cleared (reason parent_gone) and its
10
+ # cards say "answer here" again. Outside the question desk: it clears a
11
+ # mark, never cancels the question. Stops once the question is no longer
12
+ # that relay's.
13
+ module RelayWatcher
14
+ INTERVAL = 2
15
+
16
+ module_function
17
+
18
+ # @param engine [Engine] the child's
19
+ # @param parent_dir [String] the parent session's folder (its sidecar)
20
+ # @param live [#call] whether the parent's worker is up
21
+ # @return [Thread]
22
+ def start(engine:, question_id:, relay_id:, parent_dir:, interval: INTERVAL,
23
+ live: -> { WorkerSidecar.live(parent_dir, unlink: false) })
24
+ Thread.new do
25
+ loop do
26
+ sleep(interval)
27
+ break unless relayed?(engine.pending_question, question_id, relay_id)
28
+ next if live.call
29
+
30
+ Log.info(:bridge, "relay_parent_gone", id: question_id)
31
+ engine.annotate_question(question_id, relayed_to: nil, reason: "parent_gone")
32
+ break
33
+ end
34
+ rescue StandardError => e
35
+ Log.warn(:bridge, "relay_watch_failed", error: e.class.name, message: e.message)
36
+ end.tap { |thread| thread.report_on_exception = false }
37
+ end
38
+
39
+ def relayed?(pending, question_id, relay_id)
40
+ relay = pending && pending[:relayed_to]
41
+ return false unless relay.is_a?(Hash) && pending[:id].to_s == question_id.to_s
42
+
43
+ (relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
44
+ end
45
+ end
46
+ end
@@ -50,10 +50,13 @@ module Samagotchi
50
50
  # a failure replaces the note and leaves the count as it was.
51
51
  # @param owner_grace [Numeric, nil] seconds with no live worker before
52
52
  # :worker_gone (one that died before its rescue leaves it running)
53
+ # @param interject [#call, nil] called once a poll, before the sleep
54
+ # (the delegate wait relays other children's approvals there); the
55
+ # time it takes doesn't count against +timeout+
53
56
  # @return [Result]
54
57
  # @raise [ArgumentError] no such session
55
58
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
56
- baseline: nil, owner_grace: nil)
59
+ baseline: nil, owner_grace: nil, interject: nil)
57
60
  deadline = timeout && (monotonic + timeout.to_f)
58
61
  seen_running = false
59
62
  gone_since = nil
@@ -104,6 +107,12 @@ module Samagotchi
104
107
 
105
108
  return Result.new(status: :timeout) if deadline && monotonic > deadline
106
109
 
110
+ if interject
111
+ started = monotonic
112
+ interject.call
113
+ deadline += monotonic - started if deadline
114
+ next if cancelled.call
115
+ end
107
116
  sleep(poll_interval)
108
117
  end
109
118
  end
@@ -56,7 +56,7 @@ module Samagotchi
56
56
  ["profile", model ? profile_for(model, env) : "-"],
57
57
  ["thinking", model ? thinking_for(model, env) : "-"],
58
58
  ["served model", model ? served_model_for(model, env) : "-"],
59
- ["context window", context_window(env)],
59
+ ["context window", context_window(env, model)],
60
60
  ["bundles", bundles_summary],
61
61
  ["desktop", desktop_summary(env)],
62
62
  ["chi web", web_summary]
@@ -100,9 +100,29 @@ module Samagotchi
100
100
  def hooks_dir(env) = Hooks::Loader.hooks_dir(env)
101
101
 
102
102
  # Offline, so only the fallback: the running server's n_ctx wins at runtime.
103
- def context_window(env)
104
- window = ContextWindow.configured(env: env)
105
- "#{window.tokens} (#{window.source}; the server's n_ctx wins at runtime)"
103
+ # The window config gives the current model (ContextWindow: its
104
+ # models.<key>.window_tokens, its host's, then context.window_tokens or
105
+ # the default) and which of them, as a turn resolves it offline.
106
+ def context_window(env, model = nil)
107
+ tokens, where = model_window(env, model) || ContextWindow.configured(env: env).to_h.values_at(:tokens, :source)
108
+ "#{tokens} (#{where}; the server's n_ctx wins at runtime)"
109
+ end
110
+
111
+ # [tokens, "models: KEY" | "hosts.NAME"], or nil when neither is set.
112
+ def model_window(env, model)
113
+ return nil unless model
114
+
115
+ registry = HostRegistry.new(env: env)
116
+ target = registry.resolve(model)
117
+ names = registry.lookup_names(model, target: target)
118
+ models = ConfigFile.model_settings(env: env)
119
+ window = ContextWindow.setting(target, names: names, models: models)
120
+ return nil unless window
121
+ return [window.tokens, "hosts.#{target.entry.name}"] if window.source == :host_setting
122
+
123
+ [window.tokens, "models: #{ConfigFile.model_setting(names, :window_tokens, models: models).first}"]
124
+ rescue StandardError
125
+ nil
106
126
  end
107
127
 
108
128
  def model_name
@@ -111,7 +111,9 @@ module Samagotchi
111
111
  return nil unless live && pending.is_a?(Hash) && pending[:id]
112
112
 
113
113
  kind = pending[:kind].to_s
114
- { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question" }
114
+ relayed = pending[:relayed_to]
115
+ relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
116
+ { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
115
117
  end
116
118
 
117
119
  # The stored project root, else (a file from before the field) the
@@ -464,7 +466,18 @@ module Samagotchi
464
466
  entry.is_a?(Hash) ? entry.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } : entry
465
467
  end
466
468
  end
467
- message
469
+ symbolize_empty_answer(message)
470
+ end
471
+
472
+ # An empty-answer note's marker (TurnNote.empty) and its steps, as
473
+ # they were saved.
474
+ def symbolize_empty_answer(message)
475
+ marker = message[:empty_answer]
476
+ return message unless marker.is_a?(Hash)
477
+
478
+ marker = marker.transform_keys(&:to_sym)
479
+ marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
480
+ message.merge(empty_answer: marker)
468
481
  end
469
482
 
470
483
  def session_path(session_id, state_dir:)
@@ -23,6 +23,7 @@ require_relative "installed_gem"
23
23
  require_relative "recap_store"
24
24
  require_relative "archive_store"
25
25
  require_relative "image_store"
26
+ require_relative "plugin_session_state"
26
27
  require_relative "terminal_ui"
27
28
 
28
29
  module Samagotchi
@@ -240,7 +241,8 @@ module Samagotchi
240
241
  # a turn running, recap = the saved recap's first sentence, project =
241
242
  # Session#project_root, waiting = the kind of question it waits on
242
243
  # ("question", "approval", "hook"; Session#waiting_question) or nil,
243
- # waiting_id = that question's id or nil
244
+ # waiting_id = that question's id or nil, relayed_to = the parent
245
+ # whose card it waits in too (the approval relay), its short id, or nil
244
246
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
245
247
  project_root: nil, include_archived: false, sort: nil, order: nil)
246
248
  sd = state_dir || Session.default_state_dir
@@ -268,7 +270,7 @@ module Samagotchi
268
270
  # waiting: the question's kind, waiting_id: its id (what chi answer
269
271
  # --question takes); both nil with none.
270
272
  def self.waiting_fields(waiting)
271
- { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
273
+ { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
272
274
  end
273
275
  private_class_method :waiting_fields
274
276
 
@@ -598,6 +600,7 @@ module Samagotchi
598
600
  FileUtils.rm_rf(dir)
599
601
  removed << dir
600
602
  end
603
+ PluginSessionState.remove(id)
601
604
  { id: id, removed: removed, stopped: !owner.nil? }
602
605
  end
603
606
 
@@ -83,6 +83,23 @@ module Samagotchi
83
83
  nil
84
84
  end
85
85
 
86
+ # One turn's tool records from the session's saved analytics.json, in
87
+ # call order (iteration, then call_index), string-keyed as saved: what a
88
+ # join replays the turn's tool rows' durations from. Empty without a turn
89
+ # id (an older session's prompt), the file, or records.
90
+ # @param session_dir [String]
91
+ # @param turn_id [String, nil]
92
+ # @return [Array<Hash>]
93
+ def self.saved_tool_records(session_dir, turn_id)
94
+ return [] if turn_id.to_s.empty?
95
+
96
+ records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["tool_records"]
97
+ Array(records).select { |r| r.is_a?(Hash) && r["turn_id"] == turn_id }
98
+ .sort_by { |r| [r["iteration"].to_i, r["call_index"].to_i] }
99
+ rescue JSON::ParserError, SystemCallError, TypeError
100
+ []
101
+ end
102
+
86
103
  # "ctx 12%" for the session lists, "" when unknown.
87
104
  # @param pct [Float, nil]
88
105
  # @return [String]
@@ -284,7 +301,7 @@ module Samagotchi
284
301
  token_sources: [],
285
302
  retries: 0,
286
303
  model: nil,
287
- id: SecureRandom.uuid,
304
+ id: event[:turn_id] || SecureRandom.uuid,
288
305
  started_at: now.iso8601(3),
289
306
  started_monotonic: monotonic_time,
290
307
  tool_calls_by_id: {}
@@ -8,6 +8,7 @@ require_relative "config"
8
8
  require_relative "log"
9
9
  require_relative "model_profile"
10
10
  require_relative "session_manager"
11
+ require_relative "plugin_session_state"
11
12
 
12
13
  module Samagotchi
13
14
  # Which sessions go: `chi sessions prune` / `clean` (.prune) and the lazy
@@ -79,7 +80,10 @@ module Samagotchi
79
80
  next unless Time.now - File.mtime(dir) > EMPTY_GRACE_SECONDS && SessionManager.empty_session_dir?(dir)
80
81
  next if SessionManager.session_owner(name, state_dir: state_dir)
81
82
 
82
- FileUtils.rm_rf(dir) unless dry_run
83
+ unless dry_run
84
+ FileUtils.rm_rf(dir)
85
+ PluginSessionState.remove(name)
86
+ end
83
87
  name
84
88
  rescue SystemCallError
85
89
  nil
@@ -246,6 +250,7 @@ module Samagotchi
246
250
  FileUtils.rm_f(path)
247
251
  sidecar = File.join(state_dir, session.id)
248
252
  FileUtils.rm_rf(sidecar) if File.exist?(sidecar)
253
+ PluginSessionState.remove(session.id)
249
254
  deleted << session.id
250
255
  rescue StandardError
251
256
  skipped << session.id
@@ -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
@@ -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,7 +20,10 @@ 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"
25
+ require_relative "../tool_activity"
26
+ require_relative "../web/message_parts"
24
27
 
25
28
  module Samagotchi
26
29
  class TerminalUI
@@ -141,8 +144,9 @@ module Samagotchi
141
144
  # asks it to exit too, and it does unless something still needs it.
142
145
  # @return [Symbol] :detached, :closed when the worker went away,
143
146
  # :failed when the first command (--model) didn't go through, or
144
- # (wait_at_eof) :turn_failed when one of this run's prompts failed and
145
- # :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
146
150
  # @param input [#call, nil] prompt -> line (nil = Ctrl-D, raising
147
151
  # Interrupt = Ctrl-C); defaults to Reline
148
152
  def run(input: nil)
@@ -257,7 +261,9 @@ module Samagotchi
257
261
  ask(event[:pending_question])
258
262
  when :question_answered then question_answered(event)
259
263
  when :question_cancelled
260
- 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)
261
267
  # Written while the session sat idle: shown at the open prompt. One
262
268
  # collected just as a turn started describes the chat before it.
263
269
  when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
@@ -567,6 +573,7 @@ module Samagotchi
567
573
  id = (event[:origin] || {})[:enqueued_id]
568
574
  ours = @open_ids.include?(id) || !@merged_ids.empty?
569
575
  @own_failed = true if ours && event[:type] == :turn_failed
576
+ @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
570
577
  @open_ids.delete(id)
571
578
  @open_ids.subtract(@merged_ids)
572
579
  @merged_ids.clear
@@ -589,7 +596,9 @@ module Samagotchi
589
596
  # The input ended and nothing of ours is left to wait for.
590
597
  def end_of_input
591
598
  detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
592
- @own_failed ? :turn_failed : :detached
599
+ return :turn_failed if @own_failed
600
+
601
+ @own_empty ? :empty_answer : :detached
593
602
  end
594
603
 
595
604
  def unanswered_question(pending)
@@ -755,6 +764,13 @@ module Samagotchi
755
764
  end
756
765
  end
757
766
 
767
+ def question_relayed(event)
768
+ return unless @question && @question.id == event[:id].to_s
769
+
770
+ @question.relayed_to = event[:relayed_to]
771
+ @screen.set_slot(:notes, @question.slot(paint: method(:paint)))
772
+ end
773
+
758
774
  def question_answered(event)
759
775
  return unless @question
760
776
  return close_question(nil) if @answered_ids.include?(event[:id])
@@ -937,18 +953,78 @@ module Samagotchi
937
953
 
938
954
  @screen.commit(prompt_line(nil, exchange[last_user][:content]))
939
955
  Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
940
- exchange[(last_user + 1)..].select { |m| Steer.steer?(m) }.each do |m|
941
- @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
956
+ from = messages.index { |m| m.equal?(exchange[last_user]) }
957
+ render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
958
+ # A turn that ended with no answer: its notice, as live.
959
+ empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
960
+ if empty
961
+ @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
962
+ else
963
+ # The saved answer is raw: the latest keeps its thinking, and one may
964
+ # be only a tool call.
965
+ answer = exchange[(last_user + 1)..].reverse_each
966
+ .map { |m| OutputFormatter.strip_markup(m[:content]) }
967
+ .find { |text| !text.empty? }
968
+ @screen.commit(last_lines(answer)) if answer
942
969
  end
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
949
970
  render_join_notes(messages, after_exchange: true)
950
971
  end
951
972
 
973
+ # What the last exchange did, as the live turn drew it: each saved
974
+ # call's tool row (action, params, status from its result, and its
975
+ # duration from +durations+, the turn's saved tool records) and a
976
+ # plugin's steer where it came. The step texts stay out, as live (the
977
+ # ticker's).
978
+ def render_join_steps(messages, from, durations: [])
979
+ records = durations.dup
980
+ messages.each_with_index.drop(from + 1).each do |m, i|
981
+ if Steer.steer?(m)
982
+ @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
983
+ elsif %w[model assistant].include?(m[:role].to_s)
984
+ responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
985
+ Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
986
+ @screen.commit(join_tool_line(part, duration_ms: join_duration(records, part)))
987
+ end
988
+ end
989
+ end
990
+ end
991
+
992
+ # The prompt's turn's saved tool records (SessionMetrics, in this
993
+ # machine's state dir, as #attach_images): none for a prompt with no
994
+ # turn id (an older session) or a session with no analytics.
995
+ def join_tool_records(prompt)
996
+ SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id), prompt[:turn_id] || prompt["turn_id"])
997
+ rescue Session::InvalidId
998
+ []
999
+ end
1000
+
1001
+ # The duration of +part+'s call: the next record of its tool, in call
1002
+ # order, consumed with the ones it skipped. By tool and order, not by
1003
+ # iteration: an empty retry spends an iteration that left no message.
1004
+ # nil (no duration shown) for a call that left none: it never
1005
+ # finished, or a worker before turn ids saved it.
1006
+ def join_duration(records, part)
1007
+ return nil if part[:output].nil?
1008
+
1009
+ at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
1010
+ return nil unless at
1011
+
1012
+ duration = records[at]["duration_ms"]
1013
+ records.shift(at + 1)
1014
+ duration.is_a?(Numeric) ? duration : nil
1015
+ end
1016
+
1017
+ # A saved call's row; "no result" for a call the turn never answered
1018
+ # (it ended first).
1019
+ def join_tool_line(part, duration_ms: nil)
1020
+ output = part[:output]
1021
+ status = output.nil? ? "no result" : ToolActivity.tool_activity_status(output, part[:tool])
1022
+ activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
1023
+ params: part[:params], status: status }
1024
+ "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1025
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1026
+ end
1027
+
952
1028
  # The context notes that came since the last prompt (all of them in a
953
1029
  # session with none), after the exchange the header shows.
954
1030
  def render_join_notes(messages, after_exchange: false)
@@ -167,7 +167,12 @@ module Samagotchi
167
167
 
168
168
  @view.print_line(@view.format_tool_activity_line(activity))
169
169
  end
170
- @view.print_line(summary[:output])
170
+ # No answer: chi's muted notice, never an empty answer line.
171
+ if summary[:empty_answer]
172
+ @view.print_line(@view.format_empty_answer_line(summary.dig(:empty_answer, :retries)))
173
+ else
174
+ @view.print_line(summary[:output])
175
+ end
171
176
  @view.print_line("iteration limit reached") if summary[:resumable]
172
177
  end
173
178