samagotchi 0.153.0 → 0.154.1

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 (226) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -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 +82 -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 +21 -8
  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 +4 -2
  23. data/docs/internals/remote-sessions.md +7 -7
  24. data/docs/internals/web-frontend.md +2 -2
  25. data/docs/internals/web-push.md +1 -1
  26. data/docs/memory.md +2 -2
  27. data/docs/plugins.md +23 -28
  28. data/docs/sessions.md +24 -24
  29. data/docs/sub-agent.md +2 -2
  30. data/docs/system-one.md +1 -1
  31. data/docs/testing.md +31 -6
  32. data/lib/samagotchi/answer_text.rb +33 -0
  33. data/lib/samagotchi/bridge/card_places.rb +56 -7
  34. data/lib/samagotchi/bridge/card_store.rb +24 -9
  35. data/lib/samagotchi/bridge.rb +52 -32
  36. data/lib/samagotchi/bridge_client.rb +19 -14
  37. data/lib/samagotchi/broadcast/recipients.rb +2 -2
  38. data/lib/samagotchi/broadcast/triage.rb +1 -1
  39. data/lib/samagotchi/broadcast_command.rb +5 -5
  40. data/lib/samagotchi/bundle_command/try/command.rb +1 -1
  41. data/lib/samagotchi/bundle_command/try/run.rb +1 -1
  42. data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
  43. data/lib/samagotchi/bundle_command.rb +2 -2
  44. data/lib/samagotchi/bundles/btw/manifest.yml +2 -2
  45. data/lib/samagotchi/bundles/btw/plugin.rb +1 -1
  46. data/lib/samagotchi/bundles/coordinator/manifest.yml +2 -2
  47. data/lib/samagotchi/bundles/coordinator/plugin.rb +3 -2
  48. data/lib/samagotchi/bundles/decision-card/manifest.yml +4 -4
  49. data/lib/samagotchi/bundles/decision-card/plugin.rb +1 -9
  50. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  51. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  52. data/lib/samagotchi/bundles/github-pr/plugin.rb +3 -3
  53. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  54. data/lib/samagotchi/bundles/skills/plugin.rb +3 -2
  55. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -4
  56. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  57. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  58. data/lib/samagotchi/child_move.rb +3 -3
  59. data/lib/samagotchi/commands/registry.rb +6 -6
  60. data/lib/samagotchi/composer_route.rb +4 -5
  61. data/lib/samagotchi/config.rb +14 -12
  62. data/lib/samagotchi/context_candidate_choice.rb +1 -1
  63. data/lib/samagotchi/context_candidates.rb +4 -3
  64. data/lib/samagotchi/continue_offer.rb +1 -1
  65. data/lib/samagotchi/debug_log.rb +1 -1
  66. data/lib/samagotchi/degrade.rb +75 -0
  67. data/lib/samagotchi/desktop/macos/ChiRunner.swift +3 -3
  68. data/lib/samagotchi/engine/cards.rb +87 -0
  69. data/lib/samagotchi/engine/context_judge.rb +138 -0
  70. data/lib/samagotchi/engine/events.rb +131 -0
  71. data/lib/samagotchi/engine/guardrails.rb +87 -0
  72. data/lib/samagotchi/engine/hooks.rb +119 -0
  73. data/lib/samagotchi/engine/messages.rb +124 -0
  74. data/lib/samagotchi/engine/model_settings.rb +127 -0
  75. data/lib/samagotchi/engine/models.rb +261 -0
  76. data/lib/samagotchi/engine/objectives.rb +116 -0
  77. data/lib/samagotchi/engine/participants.rb +86 -0
  78. data/lib/samagotchi/engine/plugins.rb +208 -0
  79. data/lib/samagotchi/engine/questions.rb +98 -0
  80. data/lib/samagotchi/engine/recap.rb +130 -0
  81. data/lib/samagotchi/engine/session.rb +131 -0
  82. data/lib/samagotchi/engine/side_requests.rb +135 -0
  83. data/lib/samagotchi/engine/steering.rb +134 -0
  84. data/lib/samagotchi/engine/toolkit.rb +269 -0
  85. data/lib/samagotchi/engine/turn_end.rb +180 -0
  86. data/lib/samagotchi/engine/turn_helpers.rb +160 -0
  87. data/lib/samagotchi/engine/verify.rb +153 -0
  88. data/lib/samagotchi/engine.rb +62 -2646
  89. data/lib/samagotchi/engine_snapshots.rb +9 -8
  90. data/lib/samagotchi/grants.rb +575 -0
  91. data/lib/samagotchi/guardrail_wiring.rb +2 -2
  92. data/lib/samagotchi/guardrails/context.rb +2 -3
  93. data/lib/samagotchi/guardrails/parent_approvals.rb +2 -2
  94. data/lib/samagotchi/guardrails/parent_policy.rb +2 -2
  95. data/lib/samagotchi/hook_routes.rb +3 -3
  96. data/lib/samagotchi/idle_recap.rb +7 -6
  97. data/lib/samagotchi/idle_reminders.rb +3 -4
  98. data/lib/samagotchi/iteration_limit.rb +2 -2
  99. data/lib/samagotchi/kernel_loop.rb +13 -13
  100. data/lib/samagotchi/launch_mode.rb +13 -32
  101. data/lib/samagotchi/level_setting_command.rb +3 -2
  102. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  103. data/lib/samagotchi/llm_context_command.rb +3 -2
  104. data/lib/samagotchi/log.rb +7 -7
  105. data/lib/samagotchi/log_line.rb +1 -1
  106. data/lib/samagotchi/log_path.rb +1 -1
  107. data/lib/samagotchi/note_command.rb +1 -1
  108. data/lib/samagotchi/note_delivery.rb +18 -8
  109. data/lib/samagotchi/objectives.rb +24 -3
  110. data/lib/samagotchi/objectives_check.rb +8 -27
  111. data/lib/samagotchi/objectives_command.rb +4 -69
  112. data/lib/samagotchi/objectives_edit.rb +87 -0
  113. data/lib/samagotchi/output_formatter.rb +7 -1
  114. data/lib/samagotchi/owner_lock.rb +7 -1
  115. data/lib/samagotchi/parent_answers_command.rb +5 -4
  116. data/lib/samagotchi/participant_exposure.rb +42 -0
  117. data/lib/samagotchi/participant_mode.rb +1 -1
  118. data/lib/samagotchi/participant_name.rb +1 -1
  119. data/lib/samagotchi/participants.rb +17 -17
  120. data/lib/samagotchi/participants_watch.rb +1 -1
  121. data/lib/samagotchi/plugin/api.rb +3 -2
  122. data/lib/samagotchi/plugin/context.rb +21 -7
  123. data/lib/samagotchi/plugin/peer.rb +2 -1
  124. data/lib/samagotchi/plugin/sessions.rb +6 -4
  125. data/lib/samagotchi/plugin_tasks.rb +1 -2
  126. data/lib/samagotchi/private_files.rb +29 -0
  127. data/lib/samagotchi/project_env.rb +2 -2
  128. data/lib/samagotchi/question_desk.rb +8 -7
  129. data/lib/samagotchi/recap_command.rb +2 -1
  130. data/lib/samagotchi/recap_setup.rb +1 -1
  131. data/lib/samagotchi/recap_store.rb +1 -1
  132. data/lib/samagotchi/redact.rb +86 -0
  133. data/lib/samagotchi/reminder_queue.rb +2 -2
  134. data/lib/samagotchi/remote_attach_command.rb +1 -1
  135. data/lib/samagotchi/reply_wait/poll.rb +2 -2
  136. data/lib/samagotchi/review_start.rb +1 -1
  137. data/lib/samagotchi/run_options.rb +6 -6
  138. data/lib/samagotchi/self_report.rb +42 -39
  139. data/lib/samagotchi/send_command/busy.rb +1 -1
  140. data/lib/samagotchi/send_command.rb +4 -1
  141. data/lib/samagotchi/session.rb +11 -2
  142. data/lib/samagotchi/session_access/contract.rb +3 -3
  143. data/lib/samagotchi/session_access/local.rb +9 -9
  144. data/lib/samagotchi/session_access/remote.rb +17 -7
  145. data/lib/samagotchi/session_access.rb +4 -4
  146. data/lib/samagotchi/session_archive_command.rb +2 -2
  147. data/lib/samagotchi/session_chain.rb +8 -6
  148. data/lib/samagotchi/session_commands.rb +45 -48
  149. data/lib/samagotchi/session_continue.rb +1 -1
  150. data/lib/samagotchi/session_delete_command.rb +4 -4
  151. data/lib/samagotchi/session_manager.rb +67 -49
  152. data/lib/samagotchi/session_metrics.rb +2 -2
  153. data/lib/samagotchi/session_model_settings.rb +3 -2
  154. data/lib/samagotchi/session_participants.rb +1 -1
  155. data/lib/samagotchi/session_retention.rb +27 -7
  156. data/lib/samagotchi/sessions_command.rb +4 -4
  157. data/lib/samagotchi/share_command.rb +10 -10
  158. data/lib/samagotchi/steer.rb +13 -2
  159. data/lib/samagotchi/stream_event.rb +22 -0
  160. data/lib/samagotchi/system_prompt.rb +154 -145
  161. data/lib/samagotchi/task_watch.rb +1 -1
  162. data/lib/samagotchi/terminal_ui/attach_launcher.rb +72 -10
  163. data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +12 -9
  164. data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +16 -0
  165. data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +2 -3
  166. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -8
  167. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -11
  168. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -5
  169. data/lib/samagotchi/terminal_ui/formatting.rb +7 -7
  170. data/lib/samagotchi/terminal_ui/input_support.rb +17 -22
  171. data/lib/samagotchi/terminal_ui/join_replay.rb +16 -12
  172. data/lib/samagotchi/terminal_ui/line_reader.rb +2 -2
  173. data/lib/samagotchi/terminal_ui/live_region.rb +2 -2
  174. data/lib/samagotchi/terminal_ui/log_tail.rb +135 -0
  175. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +2 -2
  176. data/lib/samagotchi/terminal_ui/plain_surface.rb +1 -1
  177. data/lib/samagotchi/terminal_ui/question_prompt.rb +4 -5
  178. data/lib/samagotchi/terminal_ui/reline_seam.rb +2 -12
  179. data/lib/samagotchi/terminal_ui/status_row.rb +6 -14
  180. data/lib/samagotchi/terminal_ui.rb +142 -1387
  181. data/lib/samagotchi/title_command.rb +2 -2
  182. data/lib/samagotchi/tool_runner.rb +15 -16
  183. data/lib/samagotchi/toolkit_command.rb +2 -1
  184. data/lib/samagotchi/tools/delegate.rb +3 -1
  185. data/lib/samagotchi/tools/delegate_cursor.rb +3 -3
  186. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  187. data/lib/samagotchi/tools/objectives_tool.rb +2 -2
  188. data/lib/samagotchi/tools/send_note.rb +2 -1
  189. data/lib/samagotchi/turn_endings.rb +2 -2
  190. data/lib/samagotchi/turn_flow.rb +11 -7
  191. data/lib/samagotchi/update_hint.rb +2 -2
  192. data/lib/samagotchi/version.rb +1 -1
  193. data/lib/samagotchi/web/app.rb +42 -51
  194. data/lib/samagotchi/web/message_display.rb +7 -0
  195. data/lib/samagotchi/web/participant_access.rb +2 -2
  196. data/lib/samagotchi/web/participant_review.rb +5 -5
  197. data/lib/samagotchi/web/participant_stream_filter.rb +8 -8
  198. data/lib/samagotchi/web/participant_view.rb +2 -82
  199. data/lib/samagotchi/web/participants_admin.rb +1 -1
  200. data/lib/samagotchi/web/public/block_host.js +35 -8
  201. data/lib/samagotchi/web/public/block_renderers.css +9 -5
  202. data/lib/samagotchi/web/public/card_stamps.js +27 -0
  203. data/lib/samagotchi/web/public/cards_place.js +5 -3
  204. data/lib/samagotchi/web/public/chains.js +2 -2
  205. data/lib/samagotchi/web/public/claim_link.js +4 -4
  206. data/lib/samagotchi/web/public/composer_mode.js +1 -1
  207. data/lib/samagotchi/web/public/format.js +4 -3
  208. data/lib/samagotchi/web/public/turn_html.js +8 -4
  209. data/lib/samagotchi/web/push/store.rb +1 -1
  210. data/lib/samagotchi/web/session_access_routes.rb +13 -4
  211. data/lib/samagotchi/web/session_control.rb +12 -8
  212. data/lib/samagotchi/web/session_create.rb +1 -1
  213. data/lib/samagotchi/web/session_hub.rb +2 -2
  214. data/lib/samagotchi/web/session_show.rb +6 -2
  215. data/lib/samagotchi/web/session_summary.rb +1 -1
  216. data/lib/samagotchi/worker.rb +103 -9
  217. data/lib/samagotchi/worker_link.rb +3 -3
  218. data/lib/samagotchi/working_set_command.rb +3 -2
  219. data/lib/samagotchi.rb +0 -1
  220. metadata +31 -7
  221. data/lib/samagotchi/pending_input_queue.rb +0 -55
  222. data/lib/samagotchi/terminal_ui/between_turns.rb +0 -138
  223. data/lib/samagotchi/terminal_ui/repl_input.rb +0 -152
  224. data/lib/samagotchi/web/grants.rb +0 -575
  225. data/lib/samagotchi/web/participant_exposure.rb +0 -44
  226. data/lib/samagotchi/web/private_files.rb +0 -31
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "config"
4
4
  require_relative "log"
5
+ require_relative "degrade"
5
6
  require_relative "log_path"
6
7
  require_relative "memory_paths"
7
8
  require_relative "paths"
@@ -74,12 +75,12 @@ module Samagotchi
74
75
  @requested_memories = effective_preload_list(preload_memory_list(memories))
75
76
  # Read once, as the memories: list is: a config edit is picked up by
76
77
  # a new process. The files' text is read at each build.
77
- @config_appended = begin
78
- ConfigFile.appended_prompt_files
79
- rescue StandardError
80
- []
81
- end
78
+ @config_appended = Degrade.call(:prompt, "appended_list_unreadable", fallback: []) { ConfigFile.appended_prompt_files }
82
79
  @cli_appended = Array(appended_files).map { |path| File.expand_path(path.to_s) }
80
+ # { [chat, thinking, layers] => Built }, swapped for a new one by #reset!
81
+ @cache = {}
82
+ # The Built #build returned last; nil before a build and after #reset!.
83
+ @last = nil
83
84
  end
84
85
 
85
86
  # @return [Array<String>] the names the session preloads, known before
@@ -88,37 +89,35 @@ module Samagotchi
88
89
  @requested_memories.map { |raw| split_memory_scope(raw).last }.uniq
89
90
  end
90
91
 
92
+ # A prompt #build made, with what it read while it did: the memory
93
+ # index sizes (#memory_index), the model notes, the files and the
94
+ # plugins' sections it holds, and its stable part's length (chat).
95
+ Built = Data.define(:prompt, :memory_index, :notes, :files, :sections, :stable_length)
96
+
91
97
  # The full prompt: #base wrapped with the index and the rest. Built once
92
98
  # per loop and level (until #reset!) so the prompt prefix, and the
93
99
  # server's KV cache for it, stay stable.
100
+ #
101
+ # #reset! runs on other threads (a plugin's tools_changed! /
102
+ # prompt_changed!, a /stats re-resolving the profile) and, through the
103
+ # profile lookup, inside a build on this one. So a build reads @cache
104
+ # once and computes into locals: a reset meanwhile swaps in a new cache
105
+ # and this build's result goes into the dropped one, so it is returned
106
+ # (the same bytes as without the reset) but not kept, and the next
107
+ # build reads the sources again. No lock: the build reads memory files
108
+ # and calls the lookups, one of which takes ModelBinding's mutex, whose
109
+ # holder (a /stats thread) may itself call #reset!.
94
110
  # @param chat [Boolean] for the chat loop
95
111
  # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
96
112
  # @param layers [Array<Symbol>] the turn's LLM context layers: the
97
113
  # native prompt declares the tools they add (Tools::Registry#entries)
98
114
  def build(chat: false, thinking: nil, layers: [])
99
115
  key = [chat, thinking, layers]
100
- @built ||= {}
101
- @index_sizes ||= {}
102
- @notes_loaded ||= {}
103
- @files_loaded ||= {}
104
- @sections_loaded ||= {}
105
- unless @built.key?(key)
106
- @section_notes = nil
107
- @section_files = []
108
- @section_plugins = []
109
- @built[key] = system_prompt_with_index(
110
- assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
111
- )
112
- @index_sizes[key] = @measured_index
113
- @notes_loaded[key] = @section_notes || []
114
- @files_loaded[key] = @section_files.freeze
115
- @sections_loaded[key] = @section_plugins.freeze
116
- end
117
- @memory_index = @index_sizes[key]
118
- @prompt_notes = @notes_loaded[key]
119
- @prompt_files = @files_loaded[key]
120
- @prompt_sections = @sections_loaded[key]
121
- @built[key]
116
+ cache = @cache
117
+ built = cache[key] || (cache[key] = build_prompt(chat: chat, thinking: thinking, layers: layers))
118
+ @memory_index = built.memory_index
119
+ @last = built
120
+ built.prompt
122
121
  end
123
122
 
124
123
  # The memory indexes the prompt #build returned last holds, per scope
@@ -133,7 +132,7 @@ module Samagotchi
133
132
  # (Array<ModelNotes::Note>, [] with none), nil before a build and after
134
133
  # #reset!. Read when the prompt is built, as the section is.
135
134
  # @return [Array<ModelNotes::Note>, nil]
136
- attr_reader :prompt_notes
135
+ def prompt_notes = @last&.notes
137
136
 
138
137
  # The files whose text the prompt #build returned last holds, as
139
138
  # absolute paths in prompt order: the AGENTS.md chain, then the
@@ -141,26 +140,21 @@ module Samagotchi
141
140
  # build and after #reset!. Read when the prompt is built, as the
142
141
  # sections are.
143
142
  # @return [Array<String>]
144
- def prompt_files = @prompt_files || []
143
+ def prompt_files = @last&.files || []
145
144
 
146
145
  # The plugins' prompt sections the prompt #build returned last holds
147
146
  # (Plugin::PromptSections::Built, prompt order), [] before a build and
148
147
  # after #reset!.
149
148
  # @return [Array<Plugin::PromptSections::Built>]
150
- def prompt_sections = @prompt_sections || []
149
+ def prompt_sections = @last&.sections || []
151
150
 
152
151
  # Drops the built prompts: the next #build reads the profile, tools,
153
152
  # indexes and memories again (a model or profile switch, changed tools).
154
153
  def reset!
155
- @built = nil
156
- @index_sizes = nil
157
- @notes_loaded = nil
158
- @prompt_notes = nil
159
- @files_loaded = nil
160
- @prompt_files = nil
161
- @sections_loaded = nil
162
- @prompt_sections = nil
163
- @stable_lengths = nil
154
+ # Single assignments: a build in flight keeps the cache it read
155
+ # (#build), so it neither fails nor is kept.
156
+ @cache = {}
157
+ @last = nil
164
158
  end
165
159
 
166
160
  # The length of +prompt+'s stable part when it is a chat prompt #build
@@ -168,7 +162,12 @@ module Samagotchi
168
162
  # a remote prompt cache can keep for every session (PromptCache). nil
169
163
  # for any other text, or a prompt without those lines.
170
164
  # @return [Integer, nil]
171
- def stable_length(prompt) = @stable_lengths&.[](prompt)
165
+ def stable_length(prompt)
166
+ last = @last
167
+ return last.stable_length if last&.prompt == prompt
168
+
169
+ @cache.each_value.find { |built| built.prompt == prompt }&.stable_length
170
+ end
172
171
 
173
172
  # The base prompt (specs, plugins' declarations), with Gemma's tool
174
173
  # declarations at its end as #build has them.
@@ -185,6 +184,20 @@ module Samagotchi
185
184
 
186
185
  private
187
186
 
187
+ # One #build's work: the prompt and what it read, in a Built. The
188
+ # @section_* and @measured_* scratch is this build's (the turn thread's)
189
+ # alone: #reset! never touches it.
190
+ def build_prompt(chat:, thinking:, layers:)
191
+ @section_notes = nil
192
+ @section_files = []
193
+ @section_plugins = []
194
+ @measured_stable_length = nil
195
+ prompt = system_prompt_with_index(assist_system_prompt(chat: chat, layers: layers),
196
+ chat: chat, thinking: thinking, layers: layers)
197
+ Built.new(prompt: prompt, memory_index: @measured_index, notes: @section_notes || [],
198
+ files: @section_files.freeze, sections: @section_plugins.freeze, stable_length: @measured_stable_length)
199
+ end
200
+
188
201
  def profile = @profile_lookup.call
189
202
 
190
203
  def turn_thinking = @thinking_lookup.call
@@ -334,7 +347,7 @@ module Samagotchi
334
347
  return with_tail_declarations(stable_text, chat: chat, layers: layers) if volatile.empty?
335
348
 
336
349
  prompt = "#{stable_text}\n#{volatile.join("\n")}"
337
- (@stable_lengths ||= {})[prompt] = stable_text.length + 1 if chat
350
+ @measured_stable_length = stable_text.length + 1 if chat
338
351
  with_tail_declarations(prompt, chat: chat, layers: layers)
339
352
  end
340
353
 
@@ -386,11 +399,11 @@ module Samagotchi
386
399
  # (Plugin::PromptSections). nil with none: the prompt is what it was
387
400
  # before prompt sections.
388
401
  def plugin_prompt_section
389
- built = Array(@sections_lookup.call)
390
- @section_plugins = built
391
- Plugin::PromptSections.text(built)
392
- rescue StandardError
393
- nil
402
+ Degrade.call(:prompt, "plugin_section_failed") do
403
+ built = Array(@sections_lookup.call)
404
+ @section_plugins = built
405
+ Plugin::PromptSections.text(built)
406
+ end
394
407
  end
395
408
 
396
409
  def with_tail_declarations(prompt, chat:, layers:)
@@ -405,35 +418,35 @@ module Samagotchi
405
418
  # Identity is not tracked as an "activated" memory for the sticky status line
406
419
  # to avoid always showing `mem: identity`.
407
420
  def system_identity_section
408
- DEFAULT_SYSTEM_MEMORIES.each do |name|
409
- next if memory_muted?(name)
421
+ Degrade.call(:prompt, "identity_unreadable") do
422
+ DEFAULT_SYSTEM_MEMORIES.each do |name|
423
+ next if memory_muted?(name)
410
424
 
411
- body = Tools::MemoryRead.call(name, scope: "system", **overlay_keys)
412
- next if body.start_with?("Error:")
413
- next if body.strip.empty?
425
+ body = Tools::MemoryRead.call(name, scope: "system", **overlay_keys)
426
+ next if body.start_with?("Error:")
427
+ next if body.strip.empty?
414
428
 
415
- return "System identity (auto-loaded, scope=system):\n#{body}"
429
+ return "System identity (auto-loaded, scope=system):\n#{body}"
430
+ end
431
+ nil
416
432
  end
417
- nil
418
- rescue StandardError
419
- nil
420
433
  end
421
434
 
422
435
  # The model notes for the session's model (ModelNotes), right after
423
436
  # identity: a heading per scope, each note under its name, the models:
424
437
  # line left out. nil without any, so the prompt stays as it was.
425
438
  def model_notes_section
426
- @section_notes = []
427
- notes = Array(@model_notes_lookup.call)
428
- return nil if notes.empty?
429
-
430
- ref = @model_lookup.call&.ref
431
- notes.group_by(&:scope).map do |scope, list|
432
- heading = ref.to_s.empty? ? "Model notes (scope=#{scope}):" : "Model notes (for #{ref}, scope=#{scope}):"
433
- [heading, *list.map { |note| "## #{note.name}\n#{note.body}" }].join("\n\n")
434
- end.join("\n\n").tap { @section_notes = notes }
435
- rescue StandardError
436
- nil
439
+ Degrade.call(:prompt, "model_notes_failed") do
440
+ @section_notes = []
441
+ notes = Array(@model_notes_lookup.call)
442
+ return nil if notes.empty?
443
+
444
+ ref = @model_lookup.call&.ref
445
+ notes.group_by(&:scope).map do |scope, list|
446
+ heading = ref.to_s.empty? ? "Model notes (scope=#{scope}):" : "Model notes (for #{ref}, scope=#{scope}):"
447
+ [heading, *list.map { |note| "## #{note.name}\n#{note.body}" }].join("\n\n")
448
+ end.join("\n\n").tap { @section_notes = notes }
449
+ end
437
450
  end
438
451
 
439
452
  # ── Memory helpers ─────────────────────────────────────────────────────────
@@ -441,23 +454,23 @@ module Samagotchi
441
454
  # The session model's overlay keys, so the prompt's own memory bodies
442
455
  # (identity, preloads) get `<name>.<key>.md` as memory_read gives it.
443
456
  def overlay_keys
444
- model = @model_lookup.call
445
- return {} unless model&.key
457
+ Degrade.call(:prompt, "overlay_keys_failed", fallback: {}) do
458
+ model = @model_lookup.call
459
+ return {} unless model&.key
446
460
 
447
- { model_key: model.key, fallback_model_key: model.fallback_key }
448
- rescue StandardError
449
- {}
461
+ { model_key: model.key, fallback_model_key: model.fallback_key }
462
+ end
450
463
  end
451
464
 
452
465
  # { system: { tokens:, lines: }, project: { … } } of the index texts
453
466
  # the prompt holds; nil when they can't be measured.
454
467
  def measure_indexes(indexes)
455
- chars_per_token = MemoryBundle::IndexSize.chars_per_token
456
- %i[system project].to_h do |scope|
457
- [scope, MemoryBundle::IndexSize.of_text(scope, indexes[scope], chars_per_token: chars_per_token).figures]
468
+ Degrade.call(:prompt, "index_size_failed") do
469
+ chars_per_token = MemoryBundle::IndexSize.chars_per_token
470
+ %i[system project].to_h do |scope|
471
+ [scope, MemoryBundle::IndexSize.of_text(scope, indexes[scope], chars_per_token: chars_per_token).figures]
472
+ end
458
473
  end
459
- rescue StandardError
460
- nil
461
474
  end
462
475
 
463
476
  # The scope's index text without the muted memories' lines, nor the
@@ -473,11 +486,7 @@ module Samagotchi
473
486
  # comma-split and appended without duplicates (same ref shape as --memory:
474
487
  # bare name or scope/name).
475
488
  def preload_memory_list(cli_memories)
476
- baseline = begin
477
- ConfigFile.preloaded_memories
478
- rescue StandardError
479
- []
480
- end
489
+ baseline = Degrade.call(:prompt, "preload_list_unreadable", fallback: []) { ConfigFile.preloaded_memories }
481
490
 
482
491
  merged = Array(baseline).dup
483
492
  # For the warning when one can't be loaded: it names where it came from.
@@ -555,15 +564,15 @@ module Samagotchi
555
564
  # The AGENTS.md chain from the top of the git work tree down to the cwd
556
565
  # (PromptFiles.agents_sections), one section per file with text.
557
566
  def project_specific_description
558
- return nil if skip_agent_description?
567
+ Degrade.call(:prompt, "agents_md_unreadable") do
568
+ return nil if skip_agent_description?
559
569
 
560
- sections = PromptFiles.agents_sections(Dir.pwd)
561
- return nil if sections.empty?
570
+ sections = PromptFiles.agents_sections(Dir.pwd)
571
+ return nil if sections.empty?
562
572
 
563
- @section_files&.concat(sections.map(&:path))
564
- sections.map(&:text).join("\n\n")
565
- rescue StandardError
566
- nil
573
+ @section_files&.concat(sections.map(&:path))
574
+ sections.map(&:text).join("\n\n")
575
+ end
567
576
  end
568
577
 
569
578
  # The appended files' text, right after AGENTS.md (the stable, cached
@@ -572,26 +581,26 @@ module Samagotchi
572
581
  # then --append-system-prompt's. A file the AGENTS.md chain holds, a
573
582
  # repeat and an empty or vanished file are left out.
574
583
  def appended_section
575
- cwd = Dir.pwd
576
- top = MemoryPaths.work_tree_root(cwd)
577
- seen = Set.new((skip_agent_description? ? [] : PromptFiles.agents_paths(cwd)).map { |path| canonical_path(path) })
578
- sections = []
579
- appended_candidates(cwd).each do |path, source|
580
- next unless seen.add?(canonical_path(path))
581
-
582
- unless File.file?(path)
583
- Log.debug(:config, "appended_prompt_file_missing", path: path, source: source)
584
- next
585
- end
586
- text = PromptFiles.section(path, "Appended from #{PromptFiles.display_path(path, top: top)}:")
587
- next unless text
584
+ Degrade.call(:prompt, "appended_files_failed") do
585
+ cwd = Dir.pwd
586
+ top = MemoryPaths.work_tree_root(cwd)
587
+ seen = Set.new((skip_agent_description? ? [] : PromptFiles.agents_paths(cwd)).map { |path| canonical_path(path) })
588
+ sections = []
589
+ appended_candidates(cwd).each do |path, source|
590
+ next unless seen.add?(canonical_path(path))
591
+
592
+ unless File.file?(path)
593
+ Log.debug(:config, "appended_prompt_file_missing", path: path, source: source)
594
+ next
595
+ end
596
+ text = PromptFiles.section(path, "Appended from #{PromptFiles.display_path(path, top: top)}:")
597
+ next unless text
588
598
 
589
- @section_files&.push(path)
590
- sections << text
599
+ @section_files&.push(path)
600
+ sections << text
601
+ end
602
+ sections.empty? ? nil : sections.join("\n\n")
591
603
  end
592
- sections.empty? ? nil : sections.join("\n\n")
593
- rescue StandardError
594
- nil
595
604
  end
596
605
 
597
606
  # [absolute path, "config" | "cli"], in prompt order.
@@ -617,27 +626,27 @@ module Samagotchi
617
626
  # The home directory is spelled out once so the model copies the right
618
627
  # sequence, with the advice to write it as ~ or $HOME instead.
619
628
  def project_location
620
- cwd = Dir.pwd
621
- top = MemoryPaths.work_tree_root(cwd)
622
- lines = ["Current working directory:", cwd]
623
- if top && top != cwd
624
- lines << "Top of this checkout (where the project's commands run from; stay inside it):"
625
- lines << top
626
- elsif top
627
- lines << "This checkout (the project's files; its commands run here):"
628
- lines << top
629
- end
630
- if top && MemoryPaths.project_root(cwd) != top
631
- lines << "This checkout is a linked git worktree: read, edit, run and commit only in it, never in the " \
632
- "repository's other checkouts."
629
+ Degrade.call(:prompt, "location_failed") do
630
+ cwd = Dir.pwd
631
+ top = MemoryPaths.work_tree_root(cwd)
632
+ lines = ["Current working directory:", cwd]
633
+ if top && top != cwd
634
+ lines << "Top of this checkout (where the project's commands run from; stay inside it):"
635
+ lines << top
636
+ elsif top
637
+ lines << "This checkout (the project's files; its commands run here):"
638
+ lines << top
639
+ end
640
+ if top && MemoryPaths.project_root(cwd) != top
641
+ lines << "This checkout is a linked git worktree: read, edit, run and commit only in it, never in the " \
642
+ "repository's other checkouts."
643
+ end
644
+ home = Dir.home
645
+ lines << "Home directory: #{home} (write it as ~ or $HOME in commands and paths)" unless home.to_s.empty?
646
+ lines << "Project memories folder (your notes about the project, not its files; never work or run commands there):"
647
+ lines << Paths.tilde(Tools::MemoryRead.memories_dir("project"))
648
+ lines.join("\n")
633
649
  end
634
- home = Dir.home
635
- lines << "Home directory: #{home} (write it as ~ or $HOME in commands and paths)" unless home.to_s.empty?
636
- lines << "Project memories folder (your notes about the project, not its files; never work or run commands there):"
637
- lines << Paths.tilde(Tools::MemoryRead.memories_dir("project"))
638
- lines.join("\n")
639
- rescue StandardError
640
- nil
641
650
  end
642
651
 
643
652
  # Only while the session has participants (people its owner invited to
@@ -662,12 +671,12 @@ module Samagotchi
662
671
  # once a participant invited to act is in a working session, the review
663
672
  # line for review participants; nothing with none (nil, false).
664
673
  def participants_section
665
- mode = @participants_lookup.call
666
- return nil unless mode
674
+ Degrade.call(:prompt, "participants_line_failed") do
675
+ mode = @participants_lookup.call
676
+ return nil unless mode
667
677
 
668
- mode == ParticipantMode::PAIR ? PAIR_PARTICIPANTS_LINE : PARTICIPANTS_LINE
669
- rescue StandardError
670
- nil
678
+ mode == ParticipantMode::PAIR ? PAIR_PARTICIPANTS_LINE : PARTICIPANTS_LINE
679
+ end
671
680
  end
672
681
 
673
682
  # The model this session runs on. A model guesses its name from
@@ -675,19 +684,19 @@ module Samagotchi
675
684
  # says to answer from here. It depends only on the effective model, and
676
685
  # the prompt is rebuilt only on a switch (#reset!): no per-turn churn.
677
686
  def current_model
678
- model = @model_lookup.call
679
- return nil unless model && !model.ref.to_s.empty?
680
-
681
- details = [model.host, model.key && "model key #{model.key}", model.served && "the server says it serves #{model.served}"]
682
- details = details.compact.reject(&:empty?)
683
- where = details.empty? ? "" : " (#{details.join("; ")})"
684
- "Model: this session runs on #{model.ref}#{where}.\n" \
685
- "Asked which model you are, answer with this line, not from training: a fine-tuned model often knows only " \
686
- "its base model's name, but this session runs what is named here; it changes only with /model. " \
687
- "Guidance for this model only goes in a model note: a memory named model_notes_<name> whose first line is " \
688
- "`models: <glob on this model's id or key>`."
689
- rescue StandardError
690
- nil
687
+ Degrade.call(:prompt, "model_line_failed") do
688
+ model = @model_lookup.call
689
+ return nil unless model && !model.ref.to_s.empty?
690
+
691
+ details = [model.host, model.key && "model key #{model.key}", model.served && "the server says it serves #{model.served}"]
692
+ details = details.compact.reject(&:empty?)
693
+ where = details.empty? ? "" : " (#{details.join("; ")})"
694
+ "Model: this session runs on #{model.ref}#{where}.\n" \
695
+ "Asked which model you are, answer with this line, not from training: a fine-tuned model often knows only " \
696
+ "its base model's name, but this session runs what is named here; it changes only with /model. " \
697
+ "Guidance for this model only goes in a model note: a memory named model_notes_<name> whose first line is " \
698
+ "`models: <glob on this model's id or key>`."
699
+ end
691
700
  end
692
701
 
693
702
  # Fixed for the session's lifetime, so it doesn't churn the prompt cache.
@@ -701,7 +710,7 @@ module Samagotchi
701
710
  line = "Current session id: #{id} (resume later with `chi --resume #{id}`)"
702
711
  # The log path too: asked what went wrong, a model that has to look
703
712
  # it up guesses ~/.local/state first (the self-awareness probes).
704
- log = begin; LogPath.resolve; rescue StandardError; nil; end
713
+ log = Degrade.call(:prompt, "log_path_failed") { LogPath.resolve }
705
714
  line = "#{line}\nMy debug log: #{log} (one record per line; this session's carry sid=#{id[0, Log::SID_LENGTH]})" if log
706
715
  parent = session.parent_id.to_s
707
716
  return line if parent.empty?
@@ -9,7 +9,7 @@ require_relative "tools/task_runtime"
9
9
  module Samagotchi
10
10
  # The background tasks a worker's session started that the model hasn't
11
11
  # seen finish (docs/internals/background-tasks.md, Finish wake). Held by
12
- # the Worker (the --no-shared REPL has none) and fed by the Engine's raw
12
+ # the Worker (a --non-interactive one-shot has none) and fed by the Engine's raw
13
13
  # :tool_call_completed events (#observe), the input Bridge#own_task_ids
14
14
  # reads too; the idle loop takes the finished ones (#take_finished) for a
15
15
  # wake turn or a plain note. In memory only: a crash loses them, and they
@@ -13,14 +13,19 @@ require_relative "../launch_mode"
13
13
  require_relative "live_region"
14
14
  require_relative "plain_surface"
15
15
  require_relative "attached_loop"
16
+ require_relative "log_tail"
17
+ require_relative "formatting"
16
18
 
17
19
  module Samagotchi
18
20
  class TerminalUI
19
- # `chi --attach ID` and `chi --shared [--resume ID]`: find or start the
21
+ # `chi`, `chi --attach ID` and `chi --resume ID`: find or start the
20
22
  # session's worker, then run the TUI as a client of its Bridge
21
23
  # (AttachedLoop). It takes no OwnerLock and builds no Engine: the worker
22
24
  # owns the session, and any number of UIs can attach to it.
23
25
  module AttachLauncher
26
+ # #paint, for the -v records.
27
+ extend Formatting
28
+
24
29
  # Why attaching failed, worded for the terminal.
25
30
  class Error < StandardError; end
26
31
 
@@ -28,6 +33,13 @@ module Samagotchi
28
33
  # A connector that answered no session (its own failures raise Error
29
34
  # with their words).
30
35
  NOTHING_CONNECTED = "could not attach: no session was found or started"
36
+ # `chi scratch`'s first line, and its last once the session is gone.
37
+ SCRATCH_BANNER = "Scratch session: nothing is kept, it is deleted when you leave."
38
+ SCRATCH_DELETED = "Scratch session deleted."
39
+ # chi -v on a worker that was running before this launch (%s: the
40
+ # debug log): its level is its own.
41
+ VERBOSE_RUNNING = "(--verbose: this worker was already running and logs at the level it started with; " \
42
+ "its records show here only if it writes to %s: one chi web started may log elsewhere)"
31
43
 
32
44
  module_function
33
45
 
@@ -62,6 +74,13 @@ module Samagotchi
62
74
  # own guardrails.parent_answers; an existing session keeps its own
63
75
  # (never sent as a /parent-answers command: that would be a
64
76
  # loosening route from a command line)
77
+ # @param scratch [Boolean] `chi scratch`: a new scratch session (its
78
+ # worker's Engine saves nothing), stopped and deleted however the
79
+ # attach ends; its worker deletes it too once no client is left
80
+ # @param verbose [Boolean] -v: the session's debug records in the
81
+ # debug log (Log.path), shown above the live region as they come
82
+ # (LogTail); a worker this launch spawns logs at debug (bin/chi's
83
+ # log.level override reaches it through Config.cli_env)
65
84
  # @param state_dir [String, nil] where the sessions are (the default
66
85
  # state dir by default): the worker's, for the attached loop too
67
86
  # @param connector [#connect, nil] finds or starts the session
@@ -73,19 +92,23 @@ module Samagotchi
73
92
  # :failed when the --model switch didn't go through, or (input from a
74
93
  # pipe) :turn_failed / :empty_answer / :removed / :unanswered
75
94
  # (AttachedLoop#run)
76
- def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
95
+ def run(attach: nil, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
77
96
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
78
- toolkit_flags: [], settings: nil, parent_answers: nil, state_dir: nil, connector: nil)
97
+ toolkit_flags: [], settings: nil, parent_answers: nil, scratch: false, verbose: false, state_dir: nil,
98
+ connector: nil)
79
99
  state_dir ||= Session.default_state_dir
100
+ running_before = verbose && !connector && worker_running?(attach || resume, state_dir)
80
101
  link = connector && (connector.connect || raise(Error, NOTHING_CONNECTED))
81
102
  client = if connector
82
103
  link.client
83
104
  else
84
- connect(attach: attach, shared: shared, resume: resume, model: model, state_dir: state_dir,
105
+ connect(attach: attach, resume: resume, model: model, state_dir: state_dir,
85
106
  memories: memories, muted_memories: muted_memories, llm_context: llm_context,
86
107
  thinking: thinking, appended_prompt_files: appended_prompt_files, toolkit: toolkit,
87
- toolkit_flags: toolkit_flags, settings: settings, parent_answers: parent_answers)
108
+ toolkit_flags: toolkit_flags, settings: settings, parent_answers: parent_answers,
109
+ scratch: scratch)
88
110
  end
111
+ $stdout.puts(SCRATCH_BANNER) if scratch
89
112
  commands = []
90
113
  # --yes on /model and /thinking: typing the flag is the choice (no
91
114
  # confirm step); its applied reply ends the launch's command.
@@ -93,11 +116,13 @@ module Samagotchi
93
116
  commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
94
117
  commands << "#{ThinkingCommand::NAME} #{thinking} --yes" if (attach || resume) && thinking
95
118
  first_command = commands.size > 1 ? commands : commands.first
119
+ $stderr.puts(format(VERBOSE_RUNNING, Log.path)) if running_before
96
120
  surface = open_surface
121
+ tail = (start_log_tail(surface, client.session_id) if verbose && Log.path)
97
122
  options = { screen: surface, client_id: client_id(connector), first_prompt: prompt,
98
123
  first_command: first_command, no_interrupt: no_interrupt,
99
124
  default_input: default_input && !prompt && !attach && !resume,
100
- wait_at_eof: !$stdin.tty?,
125
+ wait_at_eof: !$stdin.tty?, scratch: scratch,
101
126
  as_parent: Guardrails::ParentApprovals.parent_process? }
102
127
  attached = if connector
103
128
  AttachedLoop.new(link: link, **options)
@@ -107,6 +132,7 @@ module Samagotchi
107
132
  ended = begin
108
133
  attached.run
109
134
  ensure
135
+ tail&.stop
110
136
  close_surface(surface)
111
137
  end
112
138
  if ended == :unanswered && attached.unanswered
@@ -119,6 +145,34 @@ module Samagotchi
119
145
  ended
120
146
  ensure
121
147
  link&.close
148
+ # Every way out (Ctrl-D, /exit, the worker gone, an error, Ctrl-C or
149
+ # a signal): only kill -9 leaves it to its worker (and the sweep).
150
+ end_scratch(client.session_id, state_dir: state_dir, quiet: !$!.nil?) if scratch && client
151
+ end
152
+
153
+ # Whether +session_id+'s worker runs now (nil: a new session).
154
+ def worker_running?(session_id, state_dir)
155
+ return false unless session_id && Session.valid_id?(session_id)
156
+
157
+ WorkerLink.resolve(Session.session_dir(session_id, state_dir: state_dir), unlink: false).live?
158
+ end
159
+
160
+ # The session's debug records, dim, above the live region.
161
+ def start_log_tail(surface, session_id)
162
+ LogTail.new(path: Log.path, session_id: session_id, on_record: lambda { |record|
163
+ surface.commit(paint(LogLine.format(record).chomp, 2))
164
+ }).start
165
+ end
166
+
167
+ # Stop the scratch session's worker and delete the session; its worker
168
+ # may have deleted it first (it found no client left).
169
+ def end_scratch(session_id, state_dir:, quiet: false, out: $stdout, err: $stderr)
170
+ SessionManager.delete_session(session_id, state_dir: state_dir, stop: true, wait: LocalLink::DELETE_WAIT)
171
+ out.puts(SCRATCH_DELETED) unless quiet
172
+ rescue ArgumentError
173
+ out.puts(SCRATCH_DELETED) unless quiet
174
+ rescue SessionManager::DeleteRefused, SystemCallError => e
175
+ err.puts("Scratch session #{session_id} was not deleted (#{e.message}): chi sessions delete #{session_id}")
122
176
  end
123
177
 
124
178
  # This TUI's client id: tui:PID, or the connector's own (a remote one
@@ -185,10 +239,11 @@ module Samagotchi
185
239
 
186
240
  # @return [BridgeClient] a client for the live Bridge of the session
187
241
  # @raise [Error]
188
- def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
242
+ def connect(attach: nil, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
189
243
  memories: [], muted_memories: [], llm_context: {}, thinking: nil, appended_prompt_files: [], toolkit: nil,
190
- toolkit_flags: [], settings: nil, parent_answers: nil, err: $stderr)
244
+ toolkit_flags: [], settings: nil, parent_answers: nil, scratch: false, err: $stderr)
191
245
  sd = state_dir || Session.default_state_dir
246
+ connected = false
192
247
  if attach || resume
193
248
  warn_memory_flags_ignored(attach || resume, memories, muted_memories, err, appended_prompt_files: appended_prompt_files,
194
249
  toolkit_flags: toolkit_flags)
@@ -211,14 +266,21 @@ module Samagotchi
211
266
  thinking: Thinking.session_level(thinking),
212
267
  appended_prompt_files: appended_prompt_files,
213
268
  toolkit: toolkit, settings: settings,
214
- parent_answers: parent_answers))
269
+ parent_answers: parent_answers),
270
+ scratch: scratch)
215
271
  end
216
272
  link = WorkerLink.await(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
217
- link.live? ? link.client(session.id) : raise(Error, format(LaunchMode::BRIDGE_LATE, session.id))
273
+ raise(Error, format(LaunchMode::BRIDGE_LATE, session.id)) unless link.live?
274
+
275
+ connected = true
276
+ link.client(session.id)
218
277
  rescue SessionManager::OwnedByTUI => e
219
278
  raise Error, e.message
220
279
  rescue ArgumentError => e
221
280
  raise Error, e.message
281
+ ensure
282
+ # A scratch session whose Bridge never came (or Ctrl-C meanwhile).
283
+ end_scratch(session.id, state_dir: sd, quiet: true, err: err) if scratch && session && !connected
222
284
  end
223
285
 
224
286
  # The session's previous worker may still be leaving: its Bridge