samagotchi 0.2.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 (243) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE +21 -0
  4. data/README.md +126 -0
  5. data/bin/chi +1140 -0
  6. data/docs/architecture.md +299 -0
  7. data/docs/cli.md +490 -0
  8. data/docs/configuration.md +494 -0
  9. data/docs/desktop.md +97 -0
  10. data/docs/guardrails.md +218 -0
  11. data/docs/hooks.md +309 -0
  12. data/docs/internals/background-tasks.md +26 -0
  13. data/docs/internals/context-telemetry.md +36 -0
  14. data/docs/internals/gemma4-contract.md +23 -0
  15. data/docs/internals/tool-guardrails.md +45 -0
  16. data/docs/memory.md +85 -0
  17. data/docs/plugins.md +819 -0
  18. data/docs/releasing.md +135 -0
  19. data/docs/sessions.md +155 -0
  20. data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
  21. data/lib/samagotchi/bridge/card_store.rb +126 -0
  22. data/lib/samagotchi/bridge/event_id.rb +25 -0
  23. data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
  24. data/lib/samagotchi/bridge/sse_writer.rb +248 -0
  25. data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
  26. data/lib/samagotchi/bridge.rb +993 -0
  27. data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
  28. data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
  29. data/lib/samagotchi/bridge_client.rb +330 -0
  30. data/lib/samagotchi/bundle_needs.rb +97 -0
  31. data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
  32. data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
  33. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
  34. data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
  35. data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
  36. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
  37. data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
  38. data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
  39. data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
  40. data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
  41. data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
  42. data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
  43. data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
  44. data/lib/samagotchi/bundles/system/delegated.md +10 -0
  45. data/lib/samagotchi/bundles/system/identity.md +7 -0
  46. data/lib/samagotchi/bundles/system/manifest.yml +11 -0
  47. data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
  48. data/lib/samagotchi/bundles/system/self_map.md +55 -0
  49. data/lib/samagotchi/cancellation_controller.rb +78 -0
  50. data/lib/samagotchi/client.rb +429 -0
  51. data/lib/samagotchi/commands/registry.rb +112 -0
  52. data/lib/samagotchi/config.rb +910 -0
  53. data/lib/samagotchi/context_note.rb +77 -0
  54. data/lib/samagotchi/context_quote.rb +21 -0
  55. data/lib/samagotchi/context_usage.rb +66 -0
  56. data/lib/samagotchi/context_window.rb +76 -0
  57. data/lib/samagotchi/debug_log.rb +110 -0
  58. data/lib/samagotchi/desktop/macos/App.swift +102 -0
  59. data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
  60. data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
  61. data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
  62. data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
  63. data/lib/samagotchi/desktop/macos.rb +255 -0
  64. data/lib/samagotchi/desktop.rb +21 -0
  65. data/lib/samagotchi/desktop_command.rb +143 -0
  66. data/lib/samagotchi/engine.rb +2807 -0
  67. data/lib/samagotchi/guardrails/approval.rb +125 -0
  68. data/lib/samagotchi/guardrails/approvals.rb +177 -0
  69. data/lib/samagotchi/guardrails/context.rb +71 -0
  70. data/lib/samagotchi/guardrails/gate.rb +125 -0
  71. data/lib/samagotchi/guardrails/load_failures.rb +46 -0
  72. data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
  73. data/lib/samagotchi/guardrails/rules.rb +199 -0
  74. data/lib/samagotchi/guardrails/targets.rb +119 -0
  75. data/lib/samagotchi/guardrails/verdict.rb +134 -0
  76. data/lib/samagotchi/guardrails.rb +18 -0
  77. data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
  78. data/lib/samagotchi/hooks/loader.rb +162 -0
  79. data/lib/samagotchi/hooks/registry.rb +261 -0
  80. data/lib/samagotchi/hooks.rb +30 -0
  81. data/lib/samagotchi/host_registry.rb +315 -0
  82. data/lib/samagotchi/idle_client.rb +147 -0
  83. data/lib/samagotchi/idle_recap.rb +549 -0
  84. data/lib/samagotchi/idle_reminders.rb +101 -0
  85. data/lib/samagotchi/idle_scheduler.rb +76 -0
  86. data/lib/samagotchi/image_store.rb +393 -0
  87. data/lib/samagotchi/installed_gem.rb +38 -0
  88. data/lib/samagotchi/kernel_loop.rb +1017 -0
  89. data/lib/samagotchi/launch_mode.rb +34 -0
  90. data/lib/samagotchi/llm/backend.rb +28 -0
  91. data/lib/samagotchi/llm/chat_loop.rb +450 -0
  92. data/lib/samagotchi/llm/errors.rb +329 -0
  93. data/lib/samagotchi/llm/http.rb +412 -0
  94. data/lib/samagotchi/llm/model_result.rb +72 -0
  95. data/lib/samagotchi/llm/native_backend.rb +50 -0
  96. data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
  97. data/lib/samagotchi/llm/openai_chat.rb +403 -0
  98. data/lib/samagotchi/llm/usage.rb +79 -0
  99. data/lib/samagotchi/log.rb +200 -0
  100. data/lib/samagotchi/log_line.rb +127 -0
  101. data/lib/samagotchi/log_path.rb +31 -0
  102. data/lib/samagotchi/log_subscriber.rb +163 -0
  103. data/lib/samagotchi/memory_bundle/builder.rb +364 -0
  104. data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
  105. data/lib/samagotchi/memory_bundle/installer.rb +528 -0
  106. data/lib/samagotchi/memory_bundle/listing.rb +72 -0
  107. data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
  108. data/lib/samagotchi/memory_bundle/merger.rb +52 -0
  109. data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
  110. data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
  111. data/lib/samagotchi/memory_bundle/source.rb +153 -0
  112. data/lib/samagotchi/memory_bundle/status.rb +107 -0
  113. data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
  114. data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
  115. data/lib/samagotchi/memory_bundle.rb +17 -0
  116. data/lib/samagotchi/memory_paths.rb +101 -0
  117. data/lib/samagotchi/model_overlay.rb +53 -0
  118. data/lib/samagotchi/model_profile.rb +309 -0
  119. data/lib/samagotchi/muted_memories.rb +66 -0
  120. data/lib/samagotchi/note_command.rb +163 -0
  121. data/lib/samagotchi/output_formatter.rb +100 -0
  122. data/lib/samagotchi/owner_lock.rb +110 -0
  123. data/lib/samagotchi/pending_input_queue.rb +48 -0
  124. data/lib/samagotchi/plugin/api.rb +362 -0
  125. data/lib/samagotchi/plugin/context.rb +193 -0
  126. data/lib/samagotchi/plugin/loader.rb +126 -0
  127. data/lib/samagotchi/plugin/service.rb +117 -0
  128. data/lib/samagotchi/plugin/sessions.rb +150 -0
  129. data/lib/samagotchi/plugin/side_question.rb +60 -0
  130. data/lib/samagotchi/plugin/tool_result.rb +24 -0
  131. data/lib/samagotchi/project_scope.rb +25 -0
  132. data/lib/samagotchi/prompt.rb +119 -0
  133. data/lib/samagotchi/prompt_literal_guard.rb +70 -0
  134. data/lib/samagotchi/recap_store.rb +92 -0
  135. data/lib/samagotchi/reminder_store.rb +165 -0
  136. data/lib/samagotchi/self_report.rb +195 -0
  137. data/lib/samagotchi/send_command.rb +170 -0
  138. data/lib/samagotchi/served_model.rb +32 -0
  139. data/lib/samagotchi/session.rb +508 -0
  140. data/lib/samagotchi/session_commands.rb +527 -0
  141. data/lib/samagotchi/session_delete_command.rb +105 -0
  142. data/lib/samagotchi/session_manager.rb +1049 -0
  143. data/lib/samagotchi/session_metrics.rb +466 -0
  144. data/lib/samagotchi/session_observer.rb +117 -0
  145. data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
  146. data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
  147. data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
  148. data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
  149. data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
  150. data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
  151. data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
  152. data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
  153. data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
  154. data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
  155. data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
  156. data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
  157. data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
  158. data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
  159. data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
  160. data/lib/samagotchi/terminal_ui/screen.rb +316 -0
  161. data/lib/samagotchi/terminal_ui/surface.rb +47 -0
  162. data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
  163. data/lib/samagotchi/terminal_ui.rb +1992 -0
  164. data/lib/samagotchi/thinking_ticker.rb +110 -0
  165. data/lib/samagotchi/thought_stream_splitter.rb +149 -0
  166. data/lib/samagotchi/token_usage.rb +88 -0
  167. data/lib/samagotchi/tool_activity.rb +216 -0
  168. data/lib/samagotchi/tool_call_parser.rb +637 -0
  169. data/lib/samagotchi/tool_declarations.rb +561 -0
  170. data/lib/samagotchi/tool_runner.rb +211 -0
  171. data/lib/samagotchi/tools/args.rb +259 -0
  172. data/lib/samagotchi/tools/ask_user_question.rb +152 -0
  173. data/lib/samagotchi/tools/builtins.rb +122 -0
  174. data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
  175. data/lib/samagotchi/tools/delegate.rb +167 -0
  176. data/lib/samagotchi/tools/delegate_result.rb +53 -0
  177. data/lib/samagotchi/tools/delegate_wait.rb +153 -0
  178. data/lib/samagotchi/tools/edit.rb +155 -0
  179. data/lib/samagotchi/tools/execute.rb +214 -0
  180. data/lib/samagotchi/tools/list_reminders.rb +20 -0
  181. data/lib/samagotchi/tools/list_sessions.rb +74 -0
  182. data/lib/samagotchi/tools/memory.rb +256 -0
  183. data/lib/samagotchi/tools/output_guardrails.rb +93 -0
  184. data/lib/samagotchi/tools/peers.rb +18 -0
  185. data/lib/samagotchi/tools/read.rb +182 -0
  186. data/lib/samagotchi/tools/register_reminder.rb +53 -0
  187. data/lib/samagotchi/tools/registry.rb +60 -0
  188. data/lib/samagotchi/tools/send_note.rb +49 -0
  189. data/lib/samagotchi/tools/task_create.rb +29 -0
  190. data/lib/samagotchi/tools/task_get.rb +39 -0
  191. data/lib/samagotchi/tools/task_list.rb +43 -0
  192. data/lib/samagotchi/tools/task_runtime.rb +311 -0
  193. data/lib/samagotchi/tools/task_stop.rb +29 -0
  194. data/lib/samagotchi/tools/task_wait.rb +104 -0
  195. data/lib/samagotchi/tools/tool_path.rb +18 -0
  196. data/lib/samagotchi/tools/web_fetch.rb +163 -0
  197. data/lib/samagotchi/tools/write.rb +26 -0
  198. data/lib/samagotchi/turn_flow.rb +242 -0
  199. data/lib/samagotchi/turn_note.rb +76 -0
  200. data/lib/samagotchi/turn_tally.rb +101 -0
  201. data/lib/samagotchi/version.rb +7 -0
  202. data/lib/samagotchi/vision_context.rb +132 -0
  203. data/lib/samagotchi/vision_support.rb +109 -0
  204. data/lib/samagotchi/web/app.rb +1349 -0
  205. data/lib/samagotchi/web/markdown_renderer.rb +107 -0
  206. data/lib/samagotchi/web/message_parts.rb +169 -0
  207. data/lib/samagotchi/web/public/activity.js +100 -0
  208. data/lib/samagotchi/web/public/annotations.js +67 -0
  209. data/lib/samagotchi/web/public/app.js +2382 -0
  210. data/lib/samagotchi/web/public/card.js +74 -0
  211. data/lib/samagotchi/web/public/chat_view.js +360 -0
  212. data/lib/samagotchi/web/public/chunk_router.js +25 -0
  213. data/lib/samagotchi/web/public/command_complete.js +39 -0
  214. data/lib/samagotchi/web/public/composer_size.js +19 -0
  215. data/lib/samagotchi/web/public/copy.js +142 -0
  216. data/lib/samagotchi/web/public/ctx.js +35 -0
  217. data/lib/samagotchi/web/public/data.js +256 -0
  218. data/lib/samagotchi/web/public/format.js +232 -0
  219. data/lib/samagotchi/web/public/hold.js +78 -0
  220. data/lib/samagotchi/web/public/images.js +77 -0
  221. data/lib/samagotchi/web/public/index.html +568 -0
  222. data/lib/samagotchi/web/public/init_row.js +60 -0
  223. data/lib/samagotchi/web/public/model_pick.js +23 -0
  224. data/lib/samagotchi/web/public/question_card.js +100 -0
  225. data/lib/samagotchi/web/public/route.js +17 -0
  226. data/lib/samagotchi/web/public/scope.js +36 -0
  227. data/lib/samagotchi/web/public/scroll.js +24 -0
  228. data/lib/samagotchi/web/public/sentences.js +88 -0
  229. data/lib/samagotchi/web/public/sessions_list.js +60 -0
  230. data/lib/samagotchi/web/public/strip.js +25 -0
  231. data/lib/samagotchi/web/public/tally.js +37 -0
  232. data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
  233. data/lib/samagotchi/web/public/timing.js +185 -0
  234. data/lib/samagotchi/web/public/turn_events.js +209 -0
  235. data/lib/samagotchi/web/public/turn_model.js +204 -0
  236. data/lib/samagotchi/web/public/turn_view.js +587 -0
  237. data/lib/samagotchi/web/server.rb +183 -0
  238. data/lib/samagotchi/web/session_hub.rb +329 -0
  239. data/lib/samagotchi/web/session_summary.rb +85 -0
  240. data/lib/samagotchi/worker.rb +635 -0
  241. data/lib/samagotchi/worker_idle_exit.rb +87 -0
  242. data/lib/samagotchi.rb +12 -0
  243. metadata +374 -0
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require_relative "surface"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # The REPL's Surface before the live region: plain output where the
9
+ # cursor is. Only the activity slot is live. It redraws in place by
10
+ # moving back up over the rows it drew last (ESC[nA), which works only
11
+ # while nothing else writes below it. The editor slot prints a prompt
12
+ # for a plain read, and clearing it erases the line Reline drew. The other
13
+ # slots just print their rows, except status rows set together with the
14
+ # activity rows (#set_slots): those are the activity block's last rows.
15
+ class LegacySurface
16
+ include Surface
17
+
18
+ # @param out [IO, nil] nil writes to $stdout as it is at each write
19
+ # (specs swap it after the UI is built)
20
+ def initialize(out: nil)
21
+ @out = out
22
+ @activity_shown = false
23
+ @activity_rows = 0
24
+ end
25
+
26
+ def commit(text)
27
+ out.puts(text)
28
+ end
29
+
30
+ def set_slot(name, rows)
31
+ check_slot!(name)
32
+ case name
33
+ when :activity then redraw_activity(rows)
34
+ when :editor
35
+ out.print(rows.join("\n"))
36
+ out.flush
37
+ else lay_out(rows, width: columns).each { |row| out.puts(row) }
38
+ end
39
+ end
40
+
41
+ def set_slots(**rows_by_slot)
42
+ return super unless rows_by_slot.key?(:activity)
43
+
44
+ activity = rows_by_slot.delete(:activity)
45
+ status = rows_by_slot.delete(:status)
46
+ set_slot(:activity, Array(activity) + Array(status))
47
+ super(**rows_by_slot)
48
+ end
49
+
50
+ def clear_slot(name)
51
+ check_slot!(name)
52
+ case name
53
+ when :activity then erase_activity
54
+ when :editor
55
+ out.print("\r\e[2K")
56
+ out.flush
57
+ true
58
+ else false
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def out = @out || $stdout
65
+
66
+ def columns
67
+ IO.console&.winsize&.last || 80
68
+ rescue StandardError
69
+ 80
70
+ end
71
+
72
+ # Pad to the taller of the old and new heights, so rows left over from
73
+ # a taller frame are blanked.
74
+ def redraw_activity(rows)
75
+ move_to_activity_origin
76
+ row_count = [@activity_rows, rows.length].max
77
+ padded = rows + Array.new(row_count - rows.length, "")
78
+ out.print(padded.map { |text| "#{text}\e[0K" }.join("\n"))
79
+ out.flush
80
+ @activity_shown = true
81
+ @activity_rows = row_count
82
+ end
83
+
84
+ def move_to_activity_origin
85
+ return unless @activity_shown
86
+
87
+ out.print("\e[#{@activity_rows - 1}A") if @activity_rows > 1
88
+ out.print("\r")
89
+ end
90
+
91
+ def erase_activity
92
+ return false unless @activity_shown
93
+
94
+ row_count = @activity_rows
95
+ row_count = 1 if row_count <= 0
96
+ out.print("\e[#{row_count - 1}A") if row_count > 1
97
+ out.print("\r")
98
+ row_count.times do |index|
99
+ out.print("\e[0K")
100
+ out.print("\n") if index < row_count - 1
101
+ end
102
+ out.print("\e[#{row_count - 1}A") if row_count > 1
103
+ out.print("\r")
104
+ out.flush
105
+ @activity_shown = false
106
+ @activity_rows = 0
107
+ true
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # Reads input lines on its own thread, so output keeps rendering while
8
+ # the user types, and hands each line (nil = Ctrl-D) or Ctrl-C to a sink
9
+ # (anything with #<<, such as a Thread::Queue); the attached loop and the
10
+ # REPL both read this way. #reprompt makes it drop the open read and start
11
+ # again with the current prompt (a question opened or closed); #prefill
12
+ # starts it again with text already typed in (a failed prompt).
13
+ class LineReader
14
+ # Raised into the reader thread; only lands inside a read.
15
+ class Reprompt < StandardError; end
16
+ # Ends the reader from inside its read, so Reline restores the terminal.
17
+ class Stop < StandardError; end
18
+
19
+ # @param prompt [#call] -> the prompt for the next read
20
+ # @param read [#call] (prompt, prefill) -> line (nil = Ctrl-D)
21
+ # @param prefill [String, nil] typed into the first read
22
+ def initialize(sink, prompt:, read:, prefill: nil)
23
+ @sink = sink
24
+ @prompt = prompt
25
+ @read = read
26
+ @current = nil
27
+ @prefill = prefill
28
+ end
29
+
30
+ # @return [String, nil] the prompt of the read in progress
31
+ attr_reader :current
32
+
33
+ def start
34
+ # Created masked (threads inherit the mask), so a Reprompt raised
35
+ # before #run is entered waits for the first read too.
36
+ Thread.handle_interrupt(Reprompt => :never, Stop => :never) { @thread = Thread.new { run } }
37
+ @thread.report_on_exception = false
38
+ self
39
+ end
40
+
41
+ def alive? = @thread&.alive? || false
42
+
43
+ # Drop the open read and start it again with the current prompt.
44
+ # @param keep_text [Boolean] with what is typed there now
45
+ # @param prefill [String, nil] with this text instead
46
+ def reprompt(keep_text: false, prefill: nil)
47
+ text = keep_text ? typed_text : prefill
48
+ @prefill = text unless text.to_s.empty?
49
+ @thread&.raise(Reprompt)
50
+ end
51
+
52
+ # Type +text+ into the next read (from the sink, between two reads).
53
+ def prefill_next(text)
54
+ @prefill = text
55
+ end
56
+
57
+ # @return [String, nil] what the open read holds
58
+ def typed_text
59
+ return nil unless $stdin.tty?
60
+
61
+ Reline.line_buffer.to_s
62
+ rescue StandardError
63
+ nil
64
+ end
65
+
66
+ # Start the read again with +text+ in it, unless something is typed
67
+ # there already (that would be lost).
68
+ # @return [Boolean] whether the text went in
69
+ def prefill(text)
70
+ return false unless line_empty?
71
+
72
+ @prefill = text
73
+ reprompt
74
+ true
75
+ end
76
+
77
+ def stop
78
+ return unless @thread&.alive?
79
+
80
+ @thread.raise(Stop)
81
+ @thread.join(0.5) || (@thread.kill && @thread.join(0.5))
82
+ end
83
+
84
+ private
85
+
86
+ def run
87
+ # A Reprompt may only interrupt the read itself; one that comes
88
+ # while a line is being handed over waits for the next read (and
89
+ # just restarts it). The mask is also inherited from #start.
90
+ Thread.handle_interrupt(Reprompt => :never, Stop => :never) do
91
+ loop do
92
+ @current = @prompt.call
93
+ prefill = @prefill
94
+ @prefill = nil
95
+ line = Thread.handle_interrupt(Reprompt => :immediate, Stop => :immediate) { @read.call(@current, prefill) }
96
+ @sink << [:line, line]
97
+ break if line.nil?
98
+ rescue Reprompt
99
+ next
100
+ rescue Interrupt
101
+ # What the line held at the press (AttachedLoop#note_interrupted_line).
102
+ @sink << [:interrupt, Thread.current[:interrupted_line]]
103
+ Thread.current[:interrupted_line] = nil
104
+ end
105
+ end
106
+ rescue Stop
107
+ nil
108
+ end
109
+
110
+ def line_empty? = typed_text.to_s.strip.empty?
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "screen"
4
+ require_relative "reline_seam"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # Opens and closes a live region: a Screen with Reline drawing into it
9
+ # through RelineSeam. The REPL and attached mode both use one when the
10
+ # terminal can show it, and fall back to their own plain output otherwise.
11
+ module LiveRegion
12
+ module_function
13
+
14
+ # @return [Screen, nil] a started Screen with Reline attached, or nil
15
+ # when the terminal can't show a live region (output or input not a
16
+ # terminal, TERM=dumb, or a Reline the seam doesn't support)
17
+ def open(out: $stdout, input: $stdin, env: ENV)
18
+ return unless available?(out: out, input: input, env: env)
19
+
20
+ screen = Screen.new(out: out).start
21
+ RelineSeam.attach(screen)
22
+ screen
23
+ end
24
+
25
+ # Hand Reline its own drawing back, then close the surface.
26
+ def close(surface)
27
+ RelineSeam.detach(surface)
28
+ surface.close
29
+ end
30
+
31
+ def available?(out:, input:, env:)
32
+ out.tty? && input.tty? && env["TERM"] != "dumb" && RelineSeam.supported?
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "monitor"
5
+ require_relative "surface"
6
+
7
+ module Samagotchi
8
+ class TerminalUI
9
+ # Attached mode's Surface when a live region can't be drawn (output or
10
+ # input not a terminal, TERM=dumb, or a Reline the seam doesn't support):
11
+ # append-only output. Committed text and the notes, status and hints rows
12
+ # print as lines. The activity slot is dropped, because redrawing it in
13
+ # place needs cursor movement (the turn's lines still print as they end).
14
+ # The prompt is Reline's (or a plain read's) own, so output may cross it.
15
+ class PlainSurface
16
+ include Surface
17
+
18
+ # @param out [IO]
19
+ def initialize(out:)
20
+ @out = out
21
+ @lock = Monitor.new
22
+ end
23
+
24
+ def synchronize(&) = @lock.synchronize(&)
25
+
26
+ # @return [Integer] terminal width in columns
27
+ def columns = IO.console&.winsize&.last || 80
28
+
29
+ def commit(text)
30
+ synchronize { @out.puts(text) }
31
+ end
32
+
33
+ def set_slot(name, rows)
34
+ check_slot!(name)
35
+ raise ArgumentError, "Reline draws the editor slot" if name == :editor
36
+ return if name == :activity
37
+
38
+ synchronize { lay_out(rows, width: columns).each { |row| @out.puts(row) } }
39
+ end
40
+
41
+ # Nothing printed can be taken back.
42
+ # @return [false]
43
+ def clear_slot(name)
44
+ check_slot!(name)
45
+ false
46
+ end
47
+
48
+ def close; end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "question_slot"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # An ask_user_question prompt as the terminal shows it: the widget as
8
+ # slot content (#slot), the line it leaves (#summary) and the parsing of
9
+ # a typed answer ("2", "1,3", "1 3; text", option labels). No I/O:
10
+ # callers draw the slot and print the messages, and
11
+ # record the answer (the REPL on its Engine, an attached UI over the
12
+ # Bridge). An approval (kind "approval") takes only a number, an exact
13
+ # label, y (the first option, Allow once) or n (the last, Deny), plus
14
+ # an optional "; reason"; a substring would pick the wrong one ("y" is
15
+ # in "Deny").
16
+ class QuestionPrompt
17
+ # A parsed answer. +error+ set: re-ask. +note+: print, then accept.
18
+ Answer = Struct.new(:selected, :freeform, :error, :note, keyword_init: true) do
19
+ def ok? = error.nil?
20
+ end
21
+
22
+ attr_reader :id, :question, :options, :header
23
+
24
+ # @param pending [Hash] Engine#pending_question (symbol or string keys)
25
+ def initialize(pending)
26
+ field = ->(key) { pending[key] || pending[key.to_s] }
27
+ @id = field.(:id).to_s
28
+ @question = field.(:question).to_s
29
+ @options = Array(field.(:options)).map { |v| v.to_s.strip }.reject(&:empty?)
30
+ header = field.(:header).to_s.strip
31
+ @header = header.empty? ? nil : header
32
+ @multi = !!field.(:multi_select)
33
+ @free = !!field.(:allow_freeform)
34
+ @approval = field.(:kind).to_s == "approval"
35
+ end
36
+
37
+ def multi? = @multi
38
+ def free? = @free
39
+ def approval? = @approval
40
+
41
+ # The widget as slot content, fitted to the rows it gets (QuestionSlot).
42
+ # @return [QuestionSlot]
43
+ def slot(paint: ->(text, _code) { text })
44
+ first, *rest = question.lines.map(&:chomp)
45
+ keys = options.each_index.map { |idx| (idx + 1).to_s }
46
+ QuestionSlot.new(header: approval? ? (header || "Approve tool call?") : header,
47
+ question: first.to_s, mark: mark, details: rest,
48
+ options: keys.zip(options).map { |key, label| QuestionSlot::Option.new(key, label) },
49
+ hint: slot_hint, question_code: approval? ? 33 : 94, paint: paint)
50
+ end
51
+
52
+ # The one line that stays in the scrollback once the question closes:
53
+ # the question and what became of it.
54
+ # @param outcome [String] the answer (#answer_text) or what closed it
55
+ def summary(outcome, paint: ->(text, _code) { text })
56
+ "#{paint.("#{mark}#{question.lines.first.to_s.chomp}", approval? ? 33 : 94)} → #{outcome}"
57
+ end
58
+
59
+ # @param answer [Answer] an accepted one
60
+ # @return [String] "Banana", "Apple, Cherry; ripe ones", "Deny: use a PR"
61
+ def answer_text(answer)
62
+ picked = answer.selected.to_a
63
+ if approval?
64
+ choice = picked.first || options.last
65
+ return answer.freeform ? "#{choice}: #{answer.freeform}" : choice
66
+ end
67
+
68
+ [picked.join(", "), answer.freeform].reject { |part| part.to_s.empty? }.join("; ")
69
+ end
70
+
71
+ # @param raw [String] the typed line, not empty
72
+ # @return [Answer]
73
+ def parse(raw)
74
+ return parse_approval(raw) if approval?
75
+
76
+ raw = raw.to_s.strip
77
+ # "1,3; my text": the first ';' separates the selection from freeform text.
78
+ sel_part, free_part = raw.include?(";") ? raw.split(";", 2).map(&:strip) : [raw, nil]
79
+ free_part = nil if free_part&.empty?
80
+ # Models forget to flag freeform: accept it anyway, with a note.
81
+ note = free_part && !free? ? "(note: freeform not flagged but accepting '#{free_part}')" : nil
82
+
83
+ labels = []
84
+ sel_part.split(/[,\s]+/).reject(&:empty?).each do |tok|
85
+ label, error = option_for(tok)
86
+ return Answer.new(error: error, note: note) if error
87
+
88
+ labels << label
89
+ end
90
+ labels.uniq!
91
+
92
+ return Answer.new(error: "No selection. Try again.", note: note) if labels.empty? && free_part.nil?
93
+ return Answer.new(error: "This is single-select (pick one). Try again.", note: note) if !multi? && labels.size > 1
94
+
95
+ Answer.new(selected: labels, freeform: free_part, note: note)
96
+ end
97
+
98
+ private
99
+
100
+ def mark = approval? ? "! " : "? "
101
+
102
+ def slot_hint
103
+ if approval?
104
+ return "1-#{options.size}, y = #{options.first}, n = #{options.last}; add '; reason' to tell the model why; " \
105
+ "Enter alone denies"
106
+ end
107
+
108
+ hint = [multi? ? "Select one or more (e.g. 1,3)" : "Select one (e.g. 2)"]
109
+ hint << "add '; text' for your own answer" if free?
110
+ hint << "Enter alone cancels"
111
+ hint.join("; ")
112
+ end
113
+
114
+ def parse_approval(raw)
115
+ sel_part, free_part = raw.to_s.strip.split(";", 2).map { |part| part.to_s.strip }
116
+ free_part = nil if free_part.to_s.empty?
117
+ return Answer.new(selected: [], freeform: free_part) if sel_part.to_s.empty? && free_part
118
+
119
+ label = approval_option(sel_part.to_s)
120
+ unless label
121
+ return Answer.new(error: "Answer with 1-#{options.size}, y (#{options.first}) or n (#{options.last}).")
122
+ end
123
+
124
+ Answer.new(selected: [label], freeform: free_part)
125
+ end
126
+
127
+ def approval_option(token)
128
+ down = token.downcase
129
+ return options.first if %w[y yes].include?(down)
130
+ return options.last if %w[n no].include?(down)
131
+
132
+ if token.match?(/\A\d+\z/)
133
+ idx = token.to_i - 1
134
+ return idx.between?(0, options.size - 1) ? options[idx] : nil
135
+ end
136
+ options.find { |o| o.downcase == down }
137
+ end
138
+
139
+ # A number, or an option label (exact or substring, any case).
140
+ def option_for(tok)
141
+ if tok.match?(/\A\d+\z/)
142
+ idx = tok.to_i - 1
143
+ return [nil, "Invalid choice '#{tok}': pick 1-#{options.size}"] if idx.negative? || idx >= options.size
144
+
145
+ return [options[idx], nil]
146
+ end
147
+
148
+ found = options.find { |o| o.downcase == tok.downcase || o.downcase.include?(tok.downcase) }
149
+ found ? [found, nil] : [nil, "Unknown option '#{tok}'. Use numbers 1-#{options.size} or exact labels."]
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reline"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # A question's rows as slot content (Surface): laid out afresh for the
8
+ # rows it gets. With room it shows everything, top down: a header, the
9
+ # question (wrapped), detail lines, one row per option and a hint row.
10
+ # When short, rows give way in this order: the hint, the details (and
11
+ # the question's wrapped rows), the header; then the options fold into
12
+ # as few packed rows as the width allows. The question's first row and
13
+ # the options stay to the last.
14
+ class QuestionSlot
15
+ # A numbered option shows as "1) Apple", a word as "yes: continue".
16
+ Option = Struct.new(:key, :label) do
17
+ def text = key.match?(/\A\d+\z/) ? "#{key}) #{label}" : "#{key}: #{label}"
18
+ end
19
+
20
+ CONTINUE_QUESTION = "The turn ran out of iterations. Continue it?"
21
+
22
+ # A continue offer (TurnFlow): what the answers at the ? prompt do.
23
+ # @param context [Hash, nil] the offer's context (last_model_intent)
24
+ def self.continue_offer(context = nil, paint: ->(text, _code) { text })
25
+ intent = (context || {})[:last_model_intent] || (context || {})["last_model_intent"]
26
+ details = intent.to_s.strip.empty? ? [] : [" last step: #{intent.to_s.strip.tr("\n", " ")}"]
27
+ new(question: CONTINUE_QUESTION, mark: "? ", details: details, question_code: 33, paint: paint,
28
+ options: [Option.new("yes", "continue (Enter alone too)"), Option.new("no", "stop here"),
29
+ Option.new("no, <reason>", "stop and tell the model why")])
30
+ end
31
+
32
+ # The line a continue offer leaves in the scrollback.
33
+ def self.continue_summary(outcome, paint: ->(text, _code) { text })
34
+ "#{paint.("? #{CONTINUE_QUESTION}", 33)} → #{outcome}"
35
+ end
36
+
37
+ # @param question [String] the first row's text, wrapped when there is room
38
+ # @param mark [String] its prefix ("? ", "! ")
39
+ # @param options [Array<Option>] key ("1", "yes") and label
40
+ # @param paint [#call] (text, code) -> coloured text
41
+ def initialize(question:, mark:, options:, header: nil, details: [], hint: nil, question_code: 94,
42
+ paint: ->(text, _code) { text })
43
+ @header = header
44
+ @question = question.to_s
45
+ @mark = mark
46
+ @details = details
47
+ @options = options
48
+ @hint = hint
49
+ @question_code = question_code
50
+ @paint = paint
51
+ end
52
+
53
+ # @param width [Integer]
54
+ # @param height [Integer, nil] nil: no limit
55
+ # @return [Array<String>]
56
+ def fit(width:, height:)
57
+ width = [width.to_i, 10].max
58
+ full = layout(width, hint: true, details: true, header: true, folded: false)
59
+ return full if height.nil? || full.size <= height
60
+
61
+ [
62
+ { hint: false, details: true, header: true, folded: false },
63
+ { hint: false, details: false, header: true, folded: false },
64
+ { hint: false, details: false, header: false, folded: false },
65
+ { hint: false, details: false, header: false, folded: true }
66
+ ].each do |level|
67
+ rows = layout(width, **level)
68
+ return rows if rows.size <= height
69
+ end
70
+ layout(width, hint: false, details: false, header: false, folded: true).first([height, 0].max)
71
+ end
72
+
73
+ private
74
+
75
+ def layout(width, hint:, details:, header:, folded:)
76
+ rows = []
77
+ rows << @paint.(@header, 1) if header && @header
78
+ question_rows = wrap(@question, width, first: @mark, indent: " " * @mark.size)
79
+ question_rows = question_rows.first(1) unless details
80
+ rows.concat(question_rows.map { |row| @paint.(row, @question_code) })
81
+ rows.concat(@details.map { |row| @paint.(row, @question_code) }) if details
82
+ rows.concat(folded ? folded_options(width) : @options.map { |o| @paint.(" #{o.text}", 92) })
83
+ rows << @paint.(" [#{@hint}]", 90) if hint && @hint
84
+ rows
85
+ end
86
+
87
+ # The options packed into rows of +width+: " 1) Allow once · 2) Deny".
88
+ def folded_options(width)
89
+ rows = []
90
+ line = +""
91
+ @options.each do |option|
92
+ item = option.text
93
+ candidate = line.empty? ? " #{item}" : "#{line} · #{item}"
94
+ if !line.empty? && display_width(candidate) > width
95
+ rows << line
96
+ line = +" #{item}"
97
+ else
98
+ line = +candidate
99
+ end
100
+ end
101
+ rows << line unless line.empty?
102
+ rows.map { |row| @paint.(row, 92) }
103
+ end
104
+
105
+ # Word wrap at +width+ columns, the first row after +first+, the others
106
+ # after +indent+; a word longer than a row is cut.
107
+ def wrap(text, width, first:, indent:)
108
+ rows = []
109
+ line = first.dup
110
+ words = false
111
+ text.split(/(?<= )/).each do |word|
112
+ if words && display_width(line + word.rstrip) > width
113
+ rows << line.rstrip
114
+ line = indent.dup
115
+ end
116
+ line << word
117
+ words = true
118
+ while display_width(line.rstrip) > width
119
+ head, = Reline::Unicode.take_mbchar_range(line, 0, width, padding: false)
120
+ rows << head
121
+ line = +"#{indent}#{line[head.size..]}"
122
+ end
123
+ end
124
+ rows << line.rstrip unless line.strip.empty? && !rows.empty?
125
+ rows
126
+ end
127
+
128
+ def display_width(text) = Reline::Unicode.calculate_width(text, true)
129
+ end
130
+ end
131
+ end