samagotchi 0.153.0 → 0.154.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 (205) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -1
  3. data/README.md +2 -3
  4. data/bin/chi +23 -24
  5. data/docs/architecture.md +30 -33
  6. data/docs/broadcast.md +5 -5
  7. data/docs/bundles.md +9 -12
  8. data/docs/cli.md +80 -96
  9. data/docs/configuration.md +27 -26
  10. data/docs/context.md +3 -3
  11. data/docs/desktop.md +1 -1
  12. data/docs/guardrails.md +8 -10
  13. data/docs/hooks.md +9 -9
  14. data/docs/internals/attached-context.md +2 -2
  15. data/docs/internals/background-tasks.md +3 -4
  16. data/docs/internals/block-renderers.md +4 -0
  17. data/docs/internals/bridge.md +3 -3
  18. data/docs/internals/context-telemetry.md +1 -1
  19. data/docs/internals/engine.md +33 -7
  20. data/docs/internals/objectives.md +6 -5
  21. data/docs/internals/participants.md +7 -7
  22. data/docs/internals/prompt-caching.md +1 -1
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-push.md +1 -1
  25. data/docs/memory.md +2 -2
  26. data/docs/plugins.md +23 -28
  27. data/docs/sessions.md +24 -24
  28. data/docs/sub-agent.md +2 -2
  29. data/docs/system-one.md +1 -1
  30. data/docs/testing.md +16 -6
  31. data/lib/samagotchi/answer_text.rb +34 -0
  32. data/lib/samagotchi/bridge.rb +43 -32
  33. data/lib/samagotchi/bridge_client.rb +10 -9
  34. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  35. data/lib/samagotchi/broadcast/triage.rb +1 -1
  36. data/lib/samagotchi/broadcast_command.rb +5 -5
  37. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  38. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  39. data/lib/samagotchi/bundle_command.rb +2 -2
  40. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  41. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  42. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  44. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  45. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  46. data/lib/samagotchi/child_move.rb +3 -3
  47. data/lib/samagotchi/commands/registry.rb +6 -6
  48. data/lib/samagotchi/composer_route.rb +4 -5
  49. data/lib/samagotchi/config.rb +14 -12
  50. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  51. data/lib/samagotchi/context_candidates.rb +4 -3
  52. data/lib/samagotchi/continue_offer.rb +1 -1
  53. data/lib/samagotchi/debug_log.rb +1 -1
  54. data/lib/samagotchi/degrade.rb +75 -0
  55. data/lib/samagotchi/desktop/macos/ChiRunner.swift +2 -2
  56. data/lib/samagotchi/engine/cards.rb +87 -0
  57. data/lib/samagotchi/engine/context_judge.rb +138 -0
  58. data/lib/samagotchi/engine/events.rb +131 -0
  59. data/lib/samagotchi/engine/guardrails.rb +87 -0
  60. data/lib/samagotchi/engine/hooks.rb +119 -0
  61. data/lib/samagotchi/engine/messages.rb +124 -0
  62. data/lib/samagotchi/engine/model_settings.rb +127 -0
  63. data/lib/samagotchi/engine/models.rb +261 -0
  64. data/lib/samagotchi/engine/objectives.rb +116 -0
  65. data/lib/samagotchi/engine/participants.rb +86 -0
  66. data/lib/samagotchi/engine/plugins.rb +208 -0
  67. data/lib/samagotchi/engine/questions.rb +98 -0
  68. data/lib/samagotchi/engine/recap.rb +130 -0
  69. data/lib/samagotchi/engine/session.rb +131 -0
  70. data/lib/samagotchi/engine/side_requests.rb +135 -0
  71. data/lib/samagotchi/engine/steering.rb +134 -0
  72. data/lib/samagotchi/engine/toolkit.rb +269 -0
  73. data/lib/samagotchi/engine/turn_end.rb +180 -0
  74. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  75. data/lib/samagotchi/engine/verify.rb +153 -0
  76. data/lib/samagotchi/engine.rb +62 -2646
  77. data/lib/samagotchi/grants.rb +575 -0
  78. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  79. data/lib/samagotchi/guardrails/context.rb +2 -3
  80. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  81. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  82. data/lib/samagotchi/hook_routes.rb +3 -3
  83. data/lib/samagotchi/idle_recap.rb +4 -5
  84. data/lib/samagotchi/idle_reminders.rb +3 -4
  85. data/lib/samagotchi/iteration_limit.rb +2 -2
  86. data/lib/samagotchi/kernel_loop.rb +10 -11
  87. data/lib/samagotchi/launch_mode.rb +13 -32
  88. data/lib/samagotchi/level_setting_command.rb +3 -2
  89. data/lib/samagotchi/llm_context_command.rb +3 -2
  90. data/lib/samagotchi/log.rb +7 -7
  91. data/lib/samagotchi/log_line.rb +1 -1
  92. data/lib/samagotchi/log_path.rb +1 -1
  93. data/lib/samagotchi/note_command.rb +1 -1
  94. data/lib/samagotchi/note_delivery.rb +18 -8
  95. data/lib/samagotchi/objectives.rb +24 -3
  96. data/lib/samagotchi/objectives_check.rb +8 -27
  97. data/lib/samagotchi/objectives_command.rb +4 -69
  98. data/lib/samagotchi/objectives_edit.rb +87 -0
  99. data/lib/samagotchi/output_formatter.rb +7 -1
  100. data/lib/samagotchi/owner_lock.rb +7 -1
  101. data/lib/samagotchi/parent_answers_command.rb +5 -4
  102. data/lib/samagotchi/participant_exposure.rb +42 -0
  103. data/lib/samagotchi/participant_mode.rb +1 -1
  104. data/lib/samagotchi/participant_name.rb +1 -1
  105. data/lib/samagotchi/participants.rb +17 -17
  106. data/lib/samagotchi/participants_watch.rb +1 -1
  107. data/lib/samagotchi/pending_input_queue.rb +2 -2
  108. data/lib/samagotchi/plugin/api.rb +3 -2
  109. data/lib/samagotchi/plugin/context.rb +21 -7
  110. data/lib/samagotchi/plugin/peer.rb +2 -1
  111. data/lib/samagotchi/plugin/sessions.rb +6 -4
  112. data/lib/samagotchi/plugin_tasks.rb +1 -2
  113. data/lib/samagotchi/private_files.rb +29 -0
  114. data/lib/samagotchi/project_env.rb +2 -2
  115. data/lib/samagotchi/question_desk.rb +8 -7
  116. data/lib/samagotchi/recap_command.rb +2 -1
  117. data/lib/samagotchi/recap_setup.rb +1 -1
  118. data/lib/samagotchi/recap_store.rb +1 -1
  119. data/lib/samagotchi/redact.rb +86 -0
  120. data/lib/samagotchi/reminder_queue.rb +2 -2
  121. data/lib/samagotchi/remote_attach_command.rb +1 -1
  122. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  123. data/lib/samagotchi/review_start.rb +1 -1
  124. data/lib/samagotchi/run_options.rb +6 -6
  125. data/lib/samagotchi/self_report.rb +42 -39
  126. data/lib/samagotchi/send_command/busy.rb +1 -1
  127. data/lib/samagotchi/send_command.rb +4 -1
  128. data/lib/samagotchi/session.rb +11 -2
  129. data/lib/samagotchi/session_access/contract.rb +3 -3
  130. data/lib/samagotchi/session_access/local.rb +9 -9
  131. data/lib/samagotchi/session_access/remote.rb +17 -7
  132. data/lib/samagotchi/session_access.rb +4 -4
  133. data/lib/samagotchi/session_archive_command.rb +2 -2
  134. data/lib/samagotchi/session_chain.rb +8 -6
  135. data/lib/samagotchi/session_commands.rb +45 -48
  136. data/lib/samagotchi/session_continue.rb +1 -1
  137. data/lib/samagotchi/session_delete_command.rb +4 -4
  138. data/lib/samagotchi/session_manager.rb +59 -36
  139. data/lib/samagotchi/session_metrics.rb +2 -2
  140. data/lib/samagotchi/session_model_settings.rb +3 -2
  141. data/lib/samagotchi/session_participants.rb +1 -1
  142. data/lib/samagotchi/session_retention.rb +3 -3
  143. data/lib/samagotchi/sessions_command.rb +4 -4
  144. data/lib/samagotchi/share_command.rb +10 -10
  145. data/lib/samagotchi/steer.rb +1 -1
  146. data/lib/samagotchi/system_prompt.rb +102 -109
  147. data/lib/samagotchi/task_watch.rb +1 -1
  148. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  149. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  150. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  151. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  152. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  153. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -3
  154. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  155. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  156. data/lib/samagotchi/terminal_ui/input_support.rb +1 -7
  157. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  158. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  159. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  160. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  161. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  163. data/lib/samagotchi/terminal_ui/reline_seam.rb +3 -3
  164. data/lib/samagotchi/terminal_ui/status_row.rb +7 -8
  165. data/lib/samagotchi/terminal_ui.rb +142 -1387
  166. data/lib/samagotchi/title_command.rb +2 -2
  167. data/lib/samagotchi/tool_runner.rb +14 -16
  168. data/lib/samagotchi/toolkit_command.rb +2 -1
  169. data/lib/samagotchi/tools/delegate.rb +3 -1
  170. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  171. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  172. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  173. data/lib/samagotchi/tools/send_note.rb +2 -1
  174. data/lib/samagotchi/turn_endings.rb +2 -2
  175. data/lib/samagotchi/turn_flow.rb +4 -5
  176. data/lib/samagotchi/update_hint.rb +2 -2
  177. data/lib/samagotchi/version.rb +1 -1
  178. data/lib/samagotchi/web/app.rb +39 -50
  179. data/lib/samagotchi/web/participant_access.rb +2 -2
  180. data/lib/samagotchi/web/participant_review.rb +5 -5
  181. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  182. data/lib/samagotchi/web/participant_view.rb +2 -82
  183. data/lib/samagotchi/web/participants_admin.rb +1 -1
  184. data/lib/samagotchi/web/public/block_host.js +35 -8
  185. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  186. data/lib/samagotchi/web/public/chains.js +2 -2
  187. data/lib/samagotchi/web/public/claim_link.js +4 -4
  188. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  189. data/lib/samagotchi/web/public/format.js +3 -3
  190. data/lib/samagotchi/web/push/store.rb +1 -1
  191. data/lib/samagotchi/web/session_access_routes.rb +5 -4
  192. data/lib/samagotchi/web/session_control.rb +12 -8
  193. data/lib/samagotchi/web/session_create.rb +1 -1
  194. data/lib/samagotchi/web/session_hub.rb +2 -2
  195. data/lib/samagotchi/web/session_show.rb +2 -2
  196. data/lib/samagotchi/web/session_summary.rb +1 -1
  197. data/lib/samagotchi/worker.rb +100 -9
  198. data/lib/samagotchi/worker_link.rb +3 -3
  199. data/lib/samagotchi/working_set_command.rb +3 -2
  200. metadata +29 -6
  201. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  202. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  203. data/lib/samagotchi/web/grants.rb +0 -575
  204. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  205. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module Objectives
5
+ # One /objectives edit line against a list, pure: what ObjectivesCommand
6
+ # applies and what a proposal (Proposal#refusal) is checked with, so the
7
+ # objectives tool refuses exactly what its card's Apply would. Reads the
8
+ # Objectives constants at a call, so objectives.rb can load it first.
9
+ module Edit
10
+ SEPARATOR = /\n|(?:\A|\s)\|(?:\s|\z)/
11
+
12
+ module_function
13
+
14
+ # What the frame shows a status as: typed at a text's start it would pass
15
+ # for one (Item#line).
16
+ def status_mark = /\A(?:\[(?:#{Objectives::STATUSES.join("|")})\]\s*)+/i
17
+
18
+ # The verbs that set a status, and the status each sets.
19
+ def marks = { "done" => Objectives::DONE, "drop" => Objectives::DROPPED, "undo" => Objectives::ACTIVE }
20
+
21
+ # @param verb [String] set, add, edit, done, drop or undo, lower case
22
+ # @param rest [String] what follows it
23
+ # @param was [String, nil] what a reword was proposed against (Objectives.fingerprint)
24
+ # @return [List, String, nil] the new list, what's wrong with the line,
25
+ # or nil for a line that isn't one
26
+ def apply(list, verb, rest, was: nil)
27
+ case verb
28
+ when "set", "add"
29
+ entries = entries_of(rest)
30
+ return nil if entries.nil? || entries.empty?
31
+
32
+ verb == "set" ? list.set(entries) : entries.reduce(list) { |so_far, e| so_far.add(e[:text], done_when: e[:done_when]) }
33
+ when "edit" then reword(list, rest, was: was)
34
+ when *marks.keys then mark(list, marks.fetch(verb), rest)
35
+ end
36
+ end
37
+
38
+ def mark(list, status, rest)
39
+ ids = rest.upcase.split(/[\s,]+/).reject(&:empty?)
40
+ return nil if ids.empty?
41
+
42
+ missing = ids.reject { |id| list.find(id) }
43
+ return Objectives.unknown(missing, list) unless missing.empty?
44
+
45
+ if status == Objectives::DONE
46
+ dropped = ids.select { |id| list.find(id).status == Objectives::DROPPED }
47
+ return Objectives.dropped(dropped) unless dropped.empty?
48
+ end
49
+
50
+ ids.reduce(list) { |so_far, id| so_far.mark(id, status) }
51
+ end
52
+
53
+ # The objective's new text: the whole rest of the line, a | too.
54
+ def reword(list, rest, was: nil)
55
+ id, text = rest.strip.split(/\s+/, 2)
56
+ entry = text && entry_of(text.gsub(/[[:space:]]+/, " ").strip)
57
+ return nil unless entry
58
+
59
+ id = id.upcase
60
+ item = list.find(id) or return Objectives.unknown(id, list)
61
+ return "#{id} has changed since this was proposed: #{item.text}" if was && Objectives.fingerprint(item) != was
62
+
63
+ list.reword(id, entry[:text], done_when: entry[:done_when])
64
+ end
65
+
66
+ # One {text:, done_when:} per line or |-part, any whitespace collapsed
67
+ # (Unicode line separators too: the frame is one line per item).
68
+ # @return [Array<Hash>, nil] nil when a part has no text of its own
69
+ def entries_of(rest)
70
+ parts = rest.split(SEPARATOR).map { |part| part.gsub(/[[:space:]]+/, " ").strip }.reject(&:empty?)
71
+ entries = parts.map { |part| entry_of(part) }
72
+ entries.all? ? entries : nil
73
+ end
74
+
75
+ # A part's text and done-when, nil when it has no text of its own (only a
76
+ # done-when, or only status marks). A leading status mark is stripped.
77
+ def entry_of(part)
78
+ found = Objectives::DONE_WHEN.match(part)
79
+ text = (found ? found[1] : part).sub(status_mark, "").strip
80
+ return nil if text.empty?
81
+
82
+ done_when = found && found[2]
83
+ { text: text, done_when: done_when.to_s.empty? ? nil : done_when }
84
+ end
85
+ end
86
+ end
87
+ end
@@ -62,6 +62,11 @@ module Samagotchi
62
62
 
63
63
  # Qwen3.6 thinking blocks: <think>...</think> (including empty)
64
64
  QWEN_THINK_RE = %r{<think>.*?</think>}m
65
+ # A Qwen thought cut off before it closed (no </think>), when the text
66
+ # opens with it (after any closed ones): the rest is thought, as with the
67
+ # Gemma channel. Only #strip_markup drops it; #remove_tokens (a streamed
68
+ # thought being shown) keeps it. A <think> mentioned mid-answer stays.
69
+ UNCLOSED_THINK_RE = /\A\s*<think>.*\z/m
65
70
  # Qwen tool_call XML blocks that survive when a tool call is rendered as text
66
71
  # e.g. <tool_call>\n<function=list_reminders>\n</function>\n</tool_call>
67
72
  QWEN_TOOL_CALL_RE = %r{<tool_call>.*?</tool_call>}m
@@ -93,7 +98,8 @@ module Samagotchi
93
98
  # a native Gemma step's closed tool-call blocks go with their bodies, as
94
99
  # Qwen's do (a step that only calls a tool has no text).
95
100
  def strip_markup(text)
96
- remove_tokens(text.to_s.gsub(GEMMA_TOOL_CALL_RE, "")).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
101
+ text = text.to_s.gsub(GEMMA_TOOL_CALL_RE, "").gsub(QWEN_THINK_RE, "").sub(UNCLOSED_THINK_RE, "")
102
+ remove_tokens(text).gsub(/\n[ \t]*\n(?:[ \t]*\n)+/, "\n\n").strip
97
103
  end
98
104
 
99
105
  def remove_tokens(text)
@@ -16,6 +16,12 @@ module Samagotchi
16
16
  # (even in the owner's own process) never releases the owner's lock.
17
17
  class OwnerLock
18
18
  FILE = "owner.lock"
19
+ # Who holds a "tui" lock now: a -p --non-interactive one-shot
20
+ # (TerminalUI), or an older chi's interactive REPL while an update is
21
+ # under way (N-1). In every refusal's words (SessionManager::OwnedByTUI
22
+ # and the callers that add what they refused); no parentheses: the
23
+ # web's continueRefusal reads "<short> (why)" out of a refusal.
24
+ TUI_BUSY = "in use by a one-turn chi --non-interactive run or an older chi's terminal"
19
25
  DEFAULT_WAIT = 2.0
20
26
  RETRY_INTERVAL = 0.05
21
27
  OWNER_READ_ATTEMPTS = 10
@@ -101,7 +107,7 @@ module Samagotchi
101
107
  # Wait for a leaving worker (Owner#leaving?) to release the lock: up to
102
108
  # its leave_wait still to run plus LEAVE_MARGIN, looking every
103
109
  # RELEASE_POLL seconds. An owner that isn't leaving (a live worker, a
104
- # stuck one, a chi REPL) or no owner returns at once. A Ctrl-C (Interrupt)
110
+ # stuck one, a one-turn chi run) or no owner returns at once. A Ctrl-C (Interrupt)
105
111
  # stops the wait as it would any sleep.
106
112
  # @param on_wait [#call, nil] called once with the Owner before waiting
107
113
  # @return [Symbol] :not_leaving, :released, or :timeout (still held)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "guardrails/parent_policy"
4
+ require_relative "session"
4
5
 
5
6
  module Samagotchi
6
7
  # /parent-answers, the session's own guardrails.parent_answers
@@ -9,13 +10,13 @@ module Samagotchi
9
10
  # soft) or `default` (none of its own) sets it, saved at once, for the next
10
11
  # answer a parent gives. It may only tighten config.yml's
11
12
  # (Guardrails::ParentPolicy.effective). Tightening goes from anyone;
12
- # loosening (toward soft, or default) only from a user at a UI: the REPL,
13
- # an attached TUI, the web (+user_ui+). A parent agent's line (chi send,
13
+ # loosening (toward soft, or default) only from a user at a UI: an
14
+ # attached TUI, the web (+user_ui+). A parent agent's line (chi send,
14
15
  # a piped or marked attach, a delegate's follow-up) is refused.
15
16
  class ParentAnswersCommand
16
17
  NAME = "/parent-answers"
17
18
  USAGE = "usage: #{NAME} [hard|standard|soft|default]".freeze
18
- REFUSED = "loosening parent_answers is the user's: #{NAME} in chi --attach, the REPL or the web " \
19
+ REFUSED = "loosening parent_answers is the user's: #{NAME} in a chi terminal or the web " \
19
20
  "(not chi send or a parent agent)".freeze
20
21
 
21
22
  # What +args+ (the text after the name) asks for.
@@ -51,7 +52,7 @@ module Samagotchi
51
52
  # @return [Array(String, Boolean, Symbol)] the reply, whether the
52
53
  # session's value changed, and :ok or :error
53
54
  def run(args, user_ui:)
54
- session = @engine.session or return ["no session yet", false, :error]
55
+ session = @engine.session or return [Session::NONE_YET, false, :error]
55
56
  kind, value = self.class.parse(args)
56
57
  return [USAGE, false, :error] if kind == :usage
57
58
  return [listing(session), false, :ok] if kind == :show
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # What a participant may learn of their review's usage, by their grant's
5
+ # exposure level (Grants::Grant#exposure; the review-composer plan's D3):
6
+ # the policy that maps a level to the usage fields the review route
7
+ # gives them (Web::ParticipantReview#review_usage). One level today:
8
+ #
9
+ # - +own+ (every grant's): their own spend and cap, their own queued
10
+ # turns and queue limit, their link's expiry, the session's model.
11
+ # Never another participant's or the owner's usage, nor the session's.
12
+ #
13
+ # A later level (a tier that also shows the session's spend, say) adds
14
+ # its row to LEVELS and its fields' sources in Web::ParticipantReview: one
15
+ # place each. A level this chi doesn't know (written by a newer chi, or
16
+ # a hand-edited store) reads as DEFAULT, the narrowest: an unknown level
17
+ # never shows more. See docs/internals/participants.md (Exposure).
18
+ module ParticipantExposure
19
+ OWN = "own"
20
+ DEFAULT = OWN
21
+ # level => the usage fields it shows, in the order the page reads them.
22
+ LEVELS = {
23
+ OWN => %i[spent cap queued queue_limit expires_at model].freeze
24
+ }.freeze
25
+
26
+ # +value+ as a level this chi knows, else DEFAULT.
27
+ def self.level(value) = LEVELS.key?(value) ? value : DEFAULT
28
+
29
+ # @return [Array<Symbol>] the fields +value+'s level shows
30
+ def self.fields(value) = LEVELS.fetch(level(value))
31
+
32
+ # The usage +value+'s level shows, each field read from +sources+
33
+ # (field => a callable): only the fields shown are computed, and a
34
+ # field a level names with no source raises (KeyError), never shows
35
+ # nothing silently.
36
+ # @param sources [Hash{Symbol => #call}]
37
+ # @return [Hash{Symbol => Object}]
38
+ def self.usage(value, sources)
39
+ fields(value).to_h { |field| [field, sources.fetch(field).call] }
40
+ end
41
+ end
42
+ end
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  end
39
39
 
40
40
  # The mode a turn of the participant +entry+ (a Participants::Entry or a
41
- # Web::Grants::Grant; nil: unknown) runs under in +session+.
41
+ # Grants::Grant; nil: unknown) runs under in +session+.
42
42
  # @return [Symbol]
43
43
  def for_turn(session, entry, features: nil)
44
44
  return REVIEW unless of(session, features: features) == PAIR
@@ -5,7 +5,7 @@ module Samagotchi
5
5
  # line of at most MAX characters with no brackets (nor Unicode line
6
6
  # breaks, nor full-width square brackets), so a name can't close or forge
7
7
  # the review-contribution header (Steer.review_header). The grants store
8
- # cleans a name at issue (Web::Grants.clean_name); the header cleans it
8
+ # cleans a name at issue (Grants.clean_name); the header cleans it
9
9
  # again, whatever a session file or a sidecar holds.
10
10
  module ParticipantName
11
11
  MAX = 64
@@ -7,12 +7,12 @@ require_relative "atomic_file"
7
7
  require_relative "participant_messages"
8
8
  require_relative "archive_store"
9
9
  require_relative "session"
10
- require_relative "web/grants"
10
+ require_relative "grants"
11
11
 
12
12
  module Samagotchi
13
13
  # A session's participants: the people (or agents) its owner invited to
14
14
  # review it (`chi share`, the owner's participants routes), each through a
15
- # Web::Grants grant. Two files in the session dir, neither in the session
15
+ # Grants grant. Two files in the session dir, neither in the session
16
16
  # JSON (chi web never saves that, and a worker would overwrite a field it
17
17
  # added):
18
18
  # - participants.json, the sidecar: who is or was invited, never a token
@@ -62,7 +62,7 @@ module Samagotchi
62
62
  []
63
63
  end
64
64
 
65
- # The sidecar rewritten from +grants+ (that session's Web::Grants::Grant
65
+ # The sidecar rewritten from +grants+ (that session's Grants::Grant
66
66
  # list). Nothing is written for a session without grants, nor for one
67
67
  # whose dir is gone.
68
68
  def write(session_id, grants, state_dir: Session.default_state_dir)
@@ -79,7 +79,7 @@ module Samagotchi
79
79
  AtomicFile.write(File.join(dir, FILE), "#{JSON.pretty_generate({ "participants" => entries })}\n", perm: 0o600)
80
80
  end
81
81
 
82
- # Web::Grants' on_write for a change to +session_ids+ (nil: the sessions
82
+ # Grants' on_write for a change to +session_ids+ (nil: the sessions
83
83
  # of the grants it changed, a revoke's result): their sidecars rewritten
84
84
  # from the grants just written, under the store's lock (two writers, chi
85
85
  # web and chi share, can't finish out of order and lose a revoke here).
@@ -100,21 +100,21 @@ module Samagotchi
100
100
  end
101
101
 
102
102
  # Invites a participant to +session_id+: the grant (+grants+, a
103
- # Web::Grants), the sidecar and the audit line. A grant bound to
103
+ # Grants), the sidecar and the audit line. A grant bound to
104
104
  # +identity+ needs +public_url+ (the chi web's Web::PublicUrl) with an
105
105
  # identity header: only a request through that proxy can carry it.
106
- # @return [Web::Grants::Issued]
107
- # @raise [Web::Grants::Invalid] an archived session, or bad values
106
+ # @return [Grants::Issued]
107
+ # @raise [Grants::Invalid] an archived session, or bad values
108
108
  # (Unbindable: an identity without such a public_url)
109
109
  # @raise [ArgumentError] no such session
110
110
  def invite(grants, session_id:, name:, kind: "human", ttl: nil, cap: nil, identity: nil, public_url: nil,
111
111
  mode: nil, state_dir: Session.default_state_dir)
112
112
  raise ArgumentError, "Session not found: #{session_id}" unless File.exist?(Session.session_file(session_id, state_dir: state_dir))
113
113
 
114
- identity = Web::Grants.clean_identity(identity) unless identity.nil?
115
- raise Web::Grants::Unbindable if !identity.nil? && public_url&.identity_header.nil?
114
+ identity = Grants.clean_identity(identity) unless identity.nil?
115
+ raise Grants::Unbindable if !identity.nil? && public_url&.identity_header.nil?
116
116
  if ArchiveStore.archived?(Session.session_dir(session_id, state_dir: state_dir))
117
- raise Web::Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
117
+ raise Grants::Invalid, "session #{session_id[0, 8]} is archived: unarchive it to invite anyone"
118
118
  end
119
119
 
120
120
  issued = grants.issue(session_id: session_id, name: name, kind: kind, ttl: ttl, cap: cap, identity: identity,
@@ -128,8 +128,8 @@ module Samagotchi
128
128
 
129
129
  # Sets participant +id+'s cap on +session_id+ (chi share cap): the
130
130
  # grant, the sidecar (the worker's check) and the audit line.
131
- # @return [Web::Grants::Grant, nil] nil when +id+ isn't an active grant there
132
- # @raise [Web::Grants::Invalid] a cap out of range
131
+ # @return [Grants::Grant, nil] nil when +id+ isn't an active grant there
132
+ # @raise [Grants::Invalid] a cap out of range
133
133
  def set_cap(grants, session_id:, id:, cap:, state_dir: Session.default_state_dir)
134
134
  grant = grants.set_cap(id, cap, session_id: session_id, on_write: sidecar_writer(state_dir, [session_id]))
135
135
  return nil unless grant
@@ -142,9 +142,9 @@ module Samagotchi
142
142
  # +session_id+.
143
143
  def no_participant(id, session_id) = "no active participant #{id} in #{session_id.to_s[0, 8]} (chi share ls lists them)"
144
144
 
145
- # Revokes grants (Web::Grants#revoke's arguments) and brings each
145
+ # Revokes grants (Grants#revoke's arguments) and brings each
146
146
  # affected session's sidecar and audit trail along.
147
- # @return [Array<Web::Grants::Grant>] the grants it revoked
147
+ # @return [Array<Grants::Grant>] the grants it revoked
148
148
  def revoke(grants, session_id: nil, id: nil, by: "owner", state_dir: Session.default_state_dir)
149
149
  revoked = grants.revoke(session_id: session_id, id: id, by: by, on_write: sidecar_writer(state_dir))
150
150
  revoked.group_by(&:session_id).each do |sid, ended|
@@ -156,15 +156,15 @@ module Samagotchi
156
156
  # Ends the grants of sessions that were archived or deleted
157
157
  # (SessionManager): +by+ "archive" or "delete". Nothing is written for a
158
158
  # session nobody was invited to.
159
- # A grants store that doesn't read (Web::Grants::Corrupt) doesn't stop
159
+ # A grants store that doesn't read (Grants::Corrupt) doesn't stop
160
160
  # the archive or delete: its links already find no grant, and the
161
161
  # store itself says so (Log.warn) until it's fixed. Nor does one a newer
162
162
  # chi wrote (Newer): chi web's gate refuses a grant whose session is
163
163
  # archived or gone even when it can't write the revoke.
164
164
  def end_sessions(session_ids, by:, state_dir: Session.default_state_dir)
165
- grants = Web::Grants.new(Web::Grants.path(state_dir: state_dir))
165
+ grants = Grants.new(Grants.path(state_dir: state_dir))
166
166
  session_ids.each { |sid| revoke(grants, session_id: sid, by: by, state_dir: state_dir) }
167
- rescue Web::Grants::Unwritable
167
+ rescue Grants::Unwritable
168
168
  nil
169
169
  end
170
170
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- # Loaded only when a sidecar is read: it brings Web::Grants (and Rack).
4
+ # Loaded only when a sidecar is read: it brings Grants.
5
5
  autoload :Participants, File.expand_path("participants", __dir__)
6
6
 
7
7
  # Who is in a session's review now, and who came or went since the last
@@ -19,8 +19,8 @@ module Samagotchi
19
19
  end
20
20
 
21
21
  # Append a message to the queue. Nil/empty strings are ignored.
22
- # +seq+ is the sender's own order number for it (the REPL numbers every
23
- # line it takes during a turn, so its leftovers keep send order across
22
+ # +seq+ is the sender's own order number for it (a sender that numbers
23
+ # every line it takes during a turn, so its leftovers keep send order across
24
24
  # this queue and its after-turn list: #drain_entries).
25
25
  def push(text, seq: nil)
26
26
  text = text.to_s
@@ -56,7 +56,8 @@ module Samagotchi
56
56
  "ctx.context.propose" => "0.140.0", "ctx.context.candidates" => "0.140.0", "ctx.context.withdraw" => "0.140.0",
57
57
  "ctx.continues" => "0.141.0", "ctx.context.left_behind" => "0.141.0", "ctx.context.attach(movable:)" => "0.141.0",
58
58
  "ctx.session_dir" => "0.143.0", "ctx.session_dir(create:)" => "0.144.0",
59
- "ctx.objectives" => "0.149.0", "ctx.turn_running?" => "0.149.0"
59
+ "ctx.objectives" => "0.149.0", "ctx.turn_running?" => "0.149.0",
60
+ "ctx.last_answer" => "0.154.0"
60
61
  }.freeze
61
62
 
62
63
  # @param bundle [String]
@@ -234,7 +235,7 @@ module Samagotchi
234
235
  # says how to stop it. It starts on first svc.value, or now with
235
236
  # eager: true (a raise then fails the plugin's load, unless the
236
237
  # plugin rescues it). The Engine stops its services when it shuts
237
- # down (the REPL or the session's worker exits), newest first.
238
+ # down (the session's worker or a one-shot exits), newest first.
238
239
  # @param name [String, Symbol] unique in the plugin
239
240
  # @return [Service]
240
241
  def service(name, eager: false, &block)
@@ -18,6 +18,9 @@ module Samagotchi
18
18
  autoload :PluginSessionState, File.expand_path("../plugin_session_state", __dir__)
19
19
  # Context#objectives reads it; the kit shouldn't carry its files.
20
20
  autoload :Objectives, File.expand_path("../objectives", __dir__)
21
+ # Context#last_answer reads them.
22
+ autoload :AnswerTail, File.expand_path("../answer_tail", __dir__)
23
+ autoload :AnswerText, File.expand_path("../answer_text", __dir__)
21
24
 
22
25
  module Plugin
23
26
  # Loaded on first ctx.sessions / ctx.context (or a bundle naming
@@ -50,7 +53,7 @@ module Samagotchi
50
53
  # model's memory overlay key (ctx.model, ctx.model_key).
51
54
  # +steer+ takes (text, label), +stop_turn+ and +stop_generation+
52
55
  # (reason, label), each true when it acted on a running turn.
53
- # +frontend+ returns the Engine's interface (:repl, :worker,
56
+ # +frontend+ returns the Engine's interface (:worker or
54
57
  # :non_interactive), which ctx.frontend names for plugins.
55
58
  # +toolkit+ returns the session's toolkit name, +declared_tools+ the
56
59
  # tool names its next turn declares, +use_toolkit+ takes a name and
@@ -260,12 +263,12 @@ module Samagotchi
260
263
  def scratch? = !!@host.scratch&.call
261
264
 
262
265
  # ctx.frontend's values, by the Engine's interface.
263
- FRONTENDS = { repl: :repl, non_interactive: :one_shot, worker: :worker }.freeze
266
+ FRONTENDS = { non_interactive: :one_shot, worker: :worker }.freeze
264
267
 
265
268
  # What runs the session, read now (the host sets it after the plugins
266
- # load): :repl (the REPL, -p without --non-interactive), :one_shot
267
- # (-p --non-interactive: one turn, then exit) or :worker (a session
268
- # worker: the web, an attached TUI). A host without one is :worker.
269
+ # load): :one_shot (-p --non-interactive: one turn, then exit) or
270
+ # :worker (a session worker: the web, an attached TUI). A host without
271
+ # one is :worker.
269
272
  # @return [Symbol]
270
273
  def frontend = FRONTENDS.fetch(@host.frontend&.call, :worker)
271
274
 
@@ -316,19 +319,30 @@ module Samagotchi
316
319
  # The conversation so far, a frozen copy, without the system prompt.
317
320
  # While a turn runs, a session worker's (attached TUI, web) adds the
318
321
  # turn so far: its prompt, the model's text and the lines merged into
319
- # it; the REPL's is the conversation before that turn
322
+ # it; a one-shot's is the conversation before that turn
320
323
  # (#messages_partial?).
321
324
  # @return [Array<Hash>]
322
325
  def messages
323
326
  Array(@host.messages.call).map { |message| message.dup.freeze }.freeze
324
327
  end
325
328
 
329
+ # The newest answer's text as the user saw it (AnswerText.visible): the
330
+ # newest bubble of #messages, its thoughts (a cut-off one too) and call
331
+ # markup out. Mid-turn a worker's can be the running turn's interim text.
332
+ # @return [String, nil] nil when there is none, or it was a thought alone
333
+ def last_answer
334
+ found = AnswerTail.find(messages)
335
+ content = found && (found[:content] || found["content"])
336
+ text = content.is_a?(String) ? AnswerText.visible(content) : ""
337
+ text.empty? ? nil : text
338
+ end
339
+
326
340
  # Whether a turn runs now. Its :after_turn hooks run inside it, so a thread
327
341
  # one of them starts can use this to wait for the end: a card shown before
328
342
  # then belongs to that turn's block.
329
343
  def turn_running? = !!@host.turn_running&.call
330
344
 
331
- # Whether #messages leaves out a running turn (the REPL mid-turn), so
345
+ # Whether #messages leaves out a running turn (a one-shot mid-turn), so
332
346
  # a plugin can say what its answer is about.
333
347
  def messages_partial? = !!@host.messages_partial&.call
334
348
 
@@ -6,6 +6,7 @@ require "open3"
6
6
  require "tempfile"
7
7
  require "time"
8
8
  require_relative "../paths"
9
+ require_relative "../session"
9
10
 
10
11
  module Samagotchi
11
12
  module Plugin
@@ -149,7 +150,7 @@ module Samagotchi
149
150
  def pair_again(ctx)
150
151
  peer = self.class
151
152
  previous = ctx.continues or return "not a continue"
152
- id = ctx.session_id or return "no session yet"
153
+ id = ctx.session_id or return Session::NONE_YET
153
154
  left = ctx.context.left_behind.find { |row| row.name == peer::INBOX }
154
155
  hint = left&.hint.to_s.strip
155
156
  return "no #{peer::INBOX} left behind" if hint.empty?
@@ -110,7 +110,7 @@ module Samagotchi
110
110
  # @param delivery [Symbol, String, nil] nil or :next_step (default),
111
111
  # :queue
112
112
  # @return [String] the id of the session it went to
113
- # @raise [Error] no such session, a REPL owns it, it wasn't taken
113
+ # @raise [Error] no such session, a one-shot owns it, it wasn't taken
114
114
  # (a worker older than queueing refuses a queue), or delivery is
115
115
  # :cut or unknown
116
116
  def send(id, text, delivery: nil)
@@ -128,8 +128,10 @@ module Samagotchi
128
128
  when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
129
129
  else raise Error, "the message to session #{sid[0, 8]} could not be written"
130
130
  end
131
+ rescue SessionManager::ScratchSession => e
132
+ raise Error, "session #{sid[0, 8]}: #{e.message}"
131
133
  rescue SessionManager::OwnedByTUI
132
- raise Error, "session #{sid[0, 8]} is open in a chi REPL, which takes no messages from others"
134
+ raise Error, "session #{sid[0, 8]} is #{OwnerLock::TUI_BUSY}, which takes no messages from others"
133
135
  end
134
136
 
135
137
  # The wire value for send's delivery: (nil for the default).
@@ -182,7 +184,7 @@ module Samagotchi
182
184
  # its worker to let go.
183
185
  # @return [String] the child's id
184
186
  # @raise [Error] not a child of this session, no such session, or a
185
- # REPL owns it
187
+ # one-shot owns it
186
188
  def stop(id)
187
189
  state_dir = self.state_dir
188
190
  sid = resolve(id, state_dir)
@@ -194,7 +196,7 @@ module Samagotchi
194
196
  SessionManager.stop_session(sid, state_dir: state_dir, wait: STOP_WAIT)
195
197
  sid
196
198
  rescue SessionManager::OwnedByTUI
197
- raise Error, "session #{sid[0, 8]} is open in a chi REPL; stop it there"
199
+ raise Error, "session #{sid[0, 8]} is #{OwnerLock::TUI_BUSY}; let it finish"
198
200
  end
199
201
 
200
202
  private
@@ -70,8 +70,7 @@ module Samagotchi
70
70
  end
71
71
 
72
72
  # Start the init tasks not started yet, each on its own thread. The
73
- # worker calls it once its Bridge is up, the REPL once it renders
74
- # events, and every turn (a -p run has only that); later calls start
73
+ # worker calls it once its Bridge is up, and every turn (a -p run has only that); later calls start
75
74
  # nothing new.
76
75
  def start!
77
76
  @mutex.synchronize do
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # chi web's private state folders (web-grants/, web-push/): a folder
5
+ # only this user can enter (0700) with files only this user can read
6
+ # (0600). chi web refuses to start on one others can read (Web::Server.launch);
7
+ # the reason words say what the folder holds.
8
+ module PrivateFiles
9
+ module_function
10
+
11
+ # Refuses +dir+ or any of +files+ when others can read it or it isn't
12
+ # this user's. Only these: the state dir above them is often 0755, and
13
+ # that is fine. A folder or file not made yet passes (it is made 0700 /
14
+ # 0600).
15
+ # @param holds [String] what is in them ("the participants' grants")
16
+ # @param error [Class] the exception to raise
17
+ def check!(dir, files, holds:, error:)
18
+ [dir, *files].each do |target|
19
+ stat = File.stat(target)
20
+ next if stat.mode.nobits?(0o077) && stat.uid == Process.uid
21
+
22
+ raise error, "#{target} can be read by other users: chmod 700 #{dir} and 600 #{files.join(" ")} " \
23
+ "(it holds #{holds})"
24
+ rescue Errno::ENOENT
25
+ next
26
+ end
27
+ end
28
+ end
29
+ end
@@ -22,7 +22,7 @@ module Samagotchi
22
22
  # chi's own settings, keys, proxies or dirs (LLM calls, HostKeys, Config,
23
23
  # Paths, hooks, plugins). A pair turn's tools don't get it either. A
24
24
  # mise.toml edit reaches a session at its next worker (`chi sessions
25
- # restart`). The REPL modes run no worker and keep the shell's env.
25
+ # restart`). A --non-interactive one-shot runs no worker and keeps the shell's env.
26
26
  # docs/sessions.md (Project env).
27
27
  #
28
28
  # And `chi` inside those tools stays the chi home's (ChiShim): the project's
@@ -75,7 +75,7 @@ module Samagotchi
75
75
  Overlay = Data.define(:env, :applied, :reason, :ruby) do
76
76
  def marker = applied ? "mise:#{ruby || "no ruby"}" : "none:#{reason}"
77
77
  end
78
- # No project env: specs, the REPL modes, a worker before #install!.
78
+ # No project env: specs, a one-shot, a worker before #install!.
79
79
  NONE = Overlay.new(env: {}.freeze, applied: false, reason: "none", ruby: nil)
80
80
 
81
81
  class Failed < StandardError; end
@@ -17,7 +17,8 @@ module Samagotchi
17
17
  # An Engine's question flow: the one open question (ask_user_question, a
18
18
  # hook's ask_user, a guardrail approval), its answer and its cancel, shared
19
19
  # by every UI. The turn thread blocks in #open_question; the UIs answer or
20
- # cancel from theirs (a REPL answers inline through the sync handler).
20
+ # cancel from theirs (a --non-interactive run dismisses it inline through
21
+ # the sync handler).
21
22
  #
22
23
  # A standing question (#post: a worker's step-limit question, kind
23
24
  # "continue") blocks nobody: it waits between turns, its answer goes to
@@ -52,7 +53,7 @@ module Samagotchi
52
53
  end
53
54
 
54
55
  # The line for the parent (ParentPolicy.message).
55
- # @param typed [Boolean] typed at a ? prompt (the REPL)
56
+ # @param typed [Boolean] typed at a terminal's ? prompt
56
57
  def detail(typed: false)
57
58
  Guardrails::ParentPolicy.message(reason, pending: @pending, strategy: @strategy, typed: typed)
58
59
  end
@@ -212,8 +213,8 @@ module Samagotchi
212
213
  id = pending.id
213
214
 
214
215
  # If a synchronous UI handler is registered (TUI), invoke it inline on the
215
- # SAME thread that called request_question (TerminalUI's REPL thread is the
216
- # turn thread — no second thread exists to answer). This avoids deadlock.
216
+ # SAME thread that called request_question (a --non-interactive run's
217
+ # TerminalUI thread is the turn thread — no second thread exists to answer). This avoids deadlock.
217
218
  # This path is TUI-specific but the surrounding emit/clear is generic, so
218
219
  # any future UI that registers a sync handler gets the same guarantee.
219
220
  if @sync_handler
@@ -369,7 +370,7 @@ module Samagotchi
369
370
 
370
371
  attr_writer :sync_handler
371
372
 
372
- # @return [Boolean] a REPL answers inline (no wait loop)
373
+ # @return [Boolean] a sync handler answers inline (a --non-interactive run: no wait loop)
373
374
  def sync_handler? = !@sync_handler.nil?
374
375
 
375
376
  # Cancel the pending question (e.g. /cancel, a dismiss). Announces which
@@ -402,8 +403,8 @@ module Samagotchi
402
403
  true
403
404
  end
404
405
 
405
- # A question through the question flow (REPL sync handler, attached TUI,
406
- # web), single-select, kind "hook". A --non-interactive run has no one
406
+ # A question through the question flow (the one-shot's sync handler,
407
+ # attached TUI, web), single-select, kind "hook". A --non-interactive run has no one
407
408
  # to ask: nil at once. Anything but an answer (a sync handler's text, no
408
409
  # answer, cancelled) is nil too.
409
410
  # @param asked [HookQuestion] its effects (label => effect for a parent
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "config"
4
+ require_relative "session"
4
5
 
5
6
  module Samagotchi
6
7
  # /recap on|off|default, the setting form of /recap (the bare /recap,
@@ -27,7 +28,7 @@ module Samagotchi
27
28
  def run(args)
28
29
  word = args.to_s.strip.downcase
29
30
  return [USAGE, false, :error] unless CHOICES.key?(word)
30
- return ["no session yet", false, :error] unless @engine.session
31
+ return [Session::NONE_YET, false, :error] unless @engine.session
31
32
 
32
33
  value = CHOICES.fetch(word)
33
34
  changed = @engine.recap_override != value
@@ -42,7 +42,7 @@ module Samagotchi
42
42
  if session.key?("recap.enabled")
43
43
  return nil unless session["recap.enabled"]
44
44
  else
45
- # The REPL and a worker pass nothing: the config is read here alone
45
+ # A worker passes nothing: the config is read here alone
46
46
  # (a scalar `recap: false` is only seen this way).
47
47
  return nil if recap.nil? && ConfigFile.recap_config == false
48
48
  return nil if Samagotchi::Config.get("recap.enabled") == false
@@ -12,7 +12,7 @@ module Samagotchi
12
12
  # (title: the auto title the recap reply led with, RecapTitle; absent in
13
13
  # a recap saved before it, or when no reply gave one).
14
14
  # A separate file, so it never races the session-file save at the end of a
15
- # turn. Follows the Engine's current session (the REPL can switch).
15
+ # turn. Follows the Engine's current session.
16
16
  class RecapStore
17
17
  FILE = "recap.json"
18
18
  # A session card's or the picker's recap line.