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,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../served_model"
4
+ require_relative "../image_store"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # Line formatting shared by the REPL and the attached view: colour, the
9
+ # `tool>` line, elapsed durations. Pure apart from reading whether
10
+ # $stdout is a colour terminal.
11
+ module Formatting
12
+ def format_tool_activity_line(activity, duration_ms: nil)
13
+ params = activity[:params].to_s.strip
14
+ params_suffix = params.empty? ? "" : " #{paint(params, 90)}"
15
+ status = activity[:status].to_s
16
+ status_color = status == "ok" ? 32 : 31
17
+ elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
18
+ "#{paint('tool>', 36)} #{activity[:action]} (#{activity[:tool]}#{params_suffix}): #{paint(status, status_color)}#{elapsed_suffix}"
19
+ end
20
+
21
+ # "[image shot.png 1280×800 · ~1.3k tokens]", dim: a turn's image.
22
+ def format_image_line(ref)
23
+ paint("[image #{ImageRef.label(ImageStore.symbolize(ref))}]", 90)
24
+ end
25
+
26
+ # " → image 1280×800" after a tool line whose tool read an image.
27
+ def format_tool_image_suffix(images)
28
+ refs = Array(images).map { |ref| ImageStore.symbolize(ref) }
29
+ return "" if refs.empty?
30
+
31
+ " #{paint(refs.map { |ref| "→ image #{ref[:width]}×#{ref[:height]}" }.join(", "), 90)}"
32
+ end
33
+
34
+ # A card (Engine#show_card) as a framed block: the title and its
35
+ # source, the body as wrapped plain text (no terminal markdown), and
36
+ # one `→ <command>` line per action, the label after it when it says
37
+ # more. +updated+: a card shown again under its id.
38
+ # @param card [Hash] title:, source:, body:, level:, actions:
39
+ # @param width [Integer, nil] columns (#card_width by default)
40
+ def card_block(card, updated: false, width: nil)
41
+ width = [(width || card_width).to_i, 24].max
42
+ rail = paint("│", 90)
43
+ title = card[:title].to_s
44
+ title += " (updated)" if updated
45
+ source = card[:source].to_s
46
+ head = paint("┌ #{title}", card[:level].to_s == "warn" ? 33 : 1)
47
+ head += paint(" · #{source}", 90) unless source.empty?
48
+ lines = [head]
49
+ wrap_plain(strip_markdown(card[:body].to_s), width - 2).each { |line| lines << (line.empty? ? rail : "#{rail} #{line}") }
50
+ Array(card[:actions]).each do |action|
51
+ command = action[:command].to_s
52
+ label = action[:label].to_s
53
+ line = "#{rail} #{paint("→ #{command}", 36)}"
54
+ line += paint(" #{label}", 90) unless label.empty? || label == command
55
+ lines << line
56
+ end
57
+ lines << paint("└", 90)
58
+ lines.join("\n")
59
+ end
60
+
61
+ # A card body's markdown made plain, cheaply (there is no terminal
62
+ # markdown): **x** and __x__ are x, backticks and code fences go, and
63
+ # so do headings' #s. Lists stay as they are.
64
+ def strip_markdown(text)
65
+ text.gsub(/^[ \t]*```.*(?:\n|\z)/, "")
66
+ .gsub(/^[ \t]{0,3}\#{1,6}[ \t]+/, "")
67
+ .gsub(/\*\*(.+?)\*\*/, '\1')
68
+ .gsub(/__(.+?)__/, '\1')
69
+ .delete("`")
70
+ end
71
+
72
+ # The columns a card is wrapped to: the terminal's (a view with a
73
+ # surface asks it).
74
+ def card_width
75
+ surface = instance_variable_defined?(:@screen) ? @screen : instance_variable_get(:@surface)
76
+ surface.respond_to?(:columns) ? surface.columns : (IO.console&.winsize&.last || 80)
77
+ end
78
+
79
+ # +text+ wrapped at word boundaries to +width+ columns, its own line
80
+ # breaks kept (a blank line stays blank), a word longer than a line
81
+ # split. Trailing blank lines dropped.
82
+ def wrap_plain(text, width)
83
+ width = [width, 8].max
84
+ lines = text.gsub("\r\n", "\n").rstrip.split("\n", -1).flat_map do |raw|
85
+ raw = raw.rstrip
86
+ next [""] if raw.empty?
87
+
88
+ indent = raw[/\A */]
89
+ out = []
90
+ line = +""
91
+ raw.split(/ +/).reject(&:empty?).each do |word|
92
+ while word.length > width - indent.length
93
+ out << "#{indent}#{line}".rstrip unless line.empty?
94
+ line = +""
95
+ out << "#{indent}#{word[0, width - indent.length]}"
96
+ word = word[(width - indent.length)..]
97
+ end
98
+ if !line.empty? && indent.length + line.length + 1 + word.length > width
99
+ out << "#{indent}#{line}"
100
+ line = +""
101
+ end
102
+ line << (line.empty? ? word : " #{word}")
103
+ end
104
+ out << "#{indent}#{line}" unless line.empty?
105
+ out
106
+ end
107
+ text.strip.empty? ? [] : lines
108
+ end
109
+
110
+ private
111
+
112
+ # The recap shown on return: dim, one "recap>" block, noting how many
113
+ # turns came since it was written.
114
+ def recap_block(text, turns_since: 0)
115
+ n = turns_since.to_i
116
+ note = if n == 1 then " (before the last turn)"
117
+ elsif n > 1 then " (before the last #{n} turns)"
118
+ else ""
119
+ end
120
+ text.to_s.strip.lines.map(&:chomp).each_with_index.map do |line, i|
121
+ paint(i.zero? ? "recap#{note}> #{line}" : line, 90)
122
+ end.join("\n")
123
+ end
124
+
125
+ RECAP_OFF_TEXT = "recap is off (recap: false in config.yml, or SAMAGOTCHI_RECAP_ENABLED=false)"
126
+
127
+ # What /recap says (the REPL's words, attached mode's too): the saved
128
+ # recap, then what came of asking for a new one.
129
+ # @param saved [Hash, nil] {text:, turns_since:}
130
+ # @param request [Symbol, String, nil] IdleRecap#request_now's answer
131
+ def recap_command_text(enabled:, saved: nil, request: nil, min_user_turns: nil)
132
+ return RECAP_OFF_TEXT unless enabled
133
+
134
+ saved = saved&.transform_keys(&:to_sym)
135
+ lines = []
136
+ lines << recap_block(saved[:text], turns_since: saved[:turns_since]) if saved
137
+ lines << case request&.to_sym
138
+ when :started, :in_flight then "writing a recap…"
139
+ when :nothing_new then saved ? "(nothing new since this recap)" : "no recap yet: nothing to recap"
140
+ when :too_short then "no recap yet: it needs at least #{min_user_turns} user turns"
141
+ when :busy then saved ? nil : "no recap yet: a turn is running; one is written once the session is idle"
142
+ when :failed then "(could not ask for a recap)"
143
+ end
144
+ lines.compact.join("\n")
145
+ end
146
+
147
+ # ── The status line (the REPL's, and attached mode's idle one) ────────
148
+
149
+ # SAMAGOTCHI_STATUS_LINE=off hides it.
150
+ def status_line_enabled?
151
+ value = ENV.fetch(STATUS_LINE_ENV, STATUS_LINE_ON).to_s.strip.downcase
152
+ !(value.empty? || value == STATUS_LINE_OFF || value == "0" || value == "false")
153
+ end
154
+
155
+ # @return [Array<String>] the "status> a | b" row, cut to +width+
156
+ def status_rows(segments, width)
157
+ return [] if segments.empty? || width <= 0
158
+
159
+ row = cap_preview_text("status> #{segments.join(' | ')}", width)
160
+ [color_output? ? paint(row, 90) : row]
161
+ end
162
+
163
+ # Longest asked-for name the status line keeps next to a served one.
164
+ STATUS_ASKED_MAX = 24
165
+
166
+ # +served+ / +served_for+: the model the server said it served for the
167
+ # name asked (see Engine#served_model); shown first when it's another
168
+ # model.
169
+ def status_model_text(model, default_model, served: nil, served_for: nil)
170
+ if ServedModel.differs?(served_for, served)
171
+ asked = model.to_s.length > STATUS_ASKED_MAX ? "#{model.to_s[0, STATUS_ASKED_MAX - 1]}…" : model
172
+ return "model=#{served} (served; asked #{asked})"
173
+ end
174
+ return "model=#{model}" if default_model.nil? || model == default_model
175
+
176
+ "model=#{model} (default: #{default_model})"
177
+ end
178
+
179
+ # @param server [Hash, nil] the server's own numbers ({ctx_pct:, prompt_tokens:, ...})
180
+ # @param estimate [Hash, nil] the kernel's estimate ({est_pct:, bucket:})
181
+ def status_context_text(server: nil, estimate: nil)
182
+ return format_server_context_segment(server) if server.is_a?(Hash)
183
+ return "" unless estimate.is_a?(Hash)
184
+
185
+ pct = format("%.1f", estimate[:est_pct].to_f)
186
+ bucket = estimate[:bucket].to_s
187
+ return "ctx=#{pct}%" if bucket.empty?
188
+
189
+ "ctx=#{pct}% (#{bucket})"
190
+ end
191
+
192
+ def format_server_context_segment(status)
193
+ pct = status[:ctx_pct]
194
+ base = pct ? "ctx=#{format('%.1f', pct)}%" : "ctx=srv"
195
+
196
+ tokens = []
197
+ prompt_tokens = status[:prompt_tokens]
198
+ completion_tokens = status[:completion_tokens]
199
+ total_tokens = status[:total_tokens]
200
+ tokens << "p=#{prompt_tokens}" if prompt_tokens
201
+ tokens << "c=#{completion_tokens}" if completion_tokens
202
+ tokens << "t=#{total_tokens}" if total_tokens
203
+
204
+ return base if tokens.empty?
205
+
206
+ "#{base} (#{tokens.join(' ')})"
207
+ end
208
+
209
+ # @param label [String] "mem" for the used memories, "muted" for the
210
+ # session's --mute list
211
+ def status_memory_text(names, limit, label: "mem")
212
+ names = Array(names)
213
+ return "" if names.empty?
214
+
215
+ visible = names.first(limit)
216
+ suffix = names.length > visible.length ? ", +#{names.length - visible.length}" : ""
217
+ "#{label}: #{visible.join(', ')}#{suffix}"
218
+ end
219
+
220
+ def cap_preview_text(text, width)
221
+ return "" if width <= 0
222
+
223
+ value = text.to_s
224
+ value.length > width ? value[0, width] : value
225
+ end
226
+
227
+ # Render the analytics snapshot as a compact, user-facing report. Raw event
228
+ # logs (debug-only) are intentionally excluded; this surface is for the REPL.
229
+ def format_session_metrics(snapshot)
230
+ return "(no metrics yet)" unless snapshot.is_a?(Hash)
231
+
232
+ # A symbol locally; a string when the snapshot came over the Bridge.
233
+ token_src = snapshot[:token_source]&.to_s
234
+ token_src_label = case token_src
235
+ when "server" then "server-reported"
236
+ when "estimate" then "estimated (chars/4)"
237
+ else "n/a"
238
+ end
239
+
240
+ lines = []
241
+ lines << "turns: #{snapshot[:turns]}"
242
+ lines << "tool calls: #{snapshot[:tool_calls_total]} (#{snapshot[:tool_errors]} errors)"
243
+ unless snapshot[:tool_calls_by_tool].to_a.empty?
244
+ by_tool = snapshot[:tool_calls_by_tool].sort_by { |_k, v| -v }
245
+ lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
246
+ end
247
+ lines << "iterations: #{snapshot[:iterations_total]}"
248
+ lines << "tokens in/out: #{snapshot[:tokens_in]}/#{snapshot[:tokens_out]} (total #{snapshot[:tokens_total]}, #{token_src_label})"
249
+ lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
250
+ lines << "cancellations: #{snapshot[:cancellations]}"
251
+ lines << "retries: #{snapshot[:retries]}"
252
+ if snapshot[:context_window_tokens]
253
+ lines << "context window: #{snapshot[:context_window_tokens]} tokens (#{snapshot[:context_window_source]})"
254
+ end
255
+ lines << "prompt profile: #{snapshot[:profile]} (#{snapshot[:profile_source]})" if snapshot[:profile]
256
+ if snapshot[:served_model]
257
+ asked = snapshot[:served_model_for]
258
+ note = ServedModel.differs?(asked, snapshot[:served_model]) ? " (asked for #{asked})" : ""
259
+ lines << "served model: #{snapshot[:served_model]}#{note}"
260
+ end
261
+ lines.join("\n")
262
+ end
263
+
264
+ def format_elapsed_duration(duration_ms)
265
+ duration_ms = duration_ms.to_f
266
+ return "" if duration_ms.negative?
267
+ return "#{duration_ms.round}ms" if duration_ms < 500
268
+
269
+ seconds = duration_ms / 1000
270
+ return "#{seconds.round(1)}s" if seconds < 10
271
+ return "#{seconds.round}s" if seconds < 60
272
+
273
+ total_seconds = seconds.round
274
+ "#{total_seconds / 60}m #{format('%02d', total_seconds % 60)}s"
275
+ end
276
+
277
+ def paint(text, code)
278
+ return text unless color_output?
279
+
280
+ "\e[#{code}m#{text}\e[0m"
281
+ end
282
+
283
+ def color_output?
284
+ return false unless $stdout.tty?
285
+ return false if ENV.key?("NO_COLOR")
286
+
287
+ ENV.fetch("TERM", "") != "dumb"
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../image_store"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # `@path` in a typed prompt: each token that names an existing image
8
+ # file (by its bytes) becomes one of the turn's images. The prompt text
9
+ # stays as typed; a token that isn't an image (a source file, a missing
10
+ # path, an email address) is just text.
11
+ #
12
+ # @shot.png @~/Desktop/a.jpg @"my shot.png" @'my shot.png'
13
+ module ImageInput
14
+ # An @ at the start or after whitespace, then a quoted or bare path.
15
+ TOKEN_RE = /(?:\A|(?<=\s))@(?:"([^"]+)"|'([^']+)'|(\S+))/
16
+
17
+ # @return [Array<Hash>] {path:} per image, in order, each path once
18
+ def self.extract(text, cwd: Dir.pwd)
19
+ text.to_s.scan(TOKEN_RE).filter_map do |quoted_double, quoted_single, bare|
20
+ raw = quoted_double || quoted_single || bare.sub(/[,.;:!?)\]]+\z/, "")
21
+ path = expand(raw, cwd)
22
+ path if path && ImageStore.image_file?(path)
23
+ end.uniq.map { |path| { path: path } }
24
+ end
25
+
26
+ def self.expand(raw, cwd)
27
+ return nil if raw.empty?
28
+
29
+ File.expand_path(raw, cwd)
30
+ rescue ArgumentError # ~unknown_user
31
+ nil
32
+ end
33
+ private_class_method :expand
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "reline"
6
+
7
+ require_relative "../memory_paths"
8
+ require_relative "../tools/memory"
9
+ require_relative "../session_commands"
10
+
11
+ module Samagotchi
12
+ class TerminalUI
13
+ # Input the REPL and the attached TUI share: the persistent prompt
14
+ # history, Tab completion (/commands, @path in the cwd, #memory), the
15
+ # #memory shorthand rewrite, multiline reads at the main prompt, and a
16
+ # one-shot prefill of the next read (the default input, a prompt given
17
+ # back after a failed turn).
18
+ #
19
+ # Included for private use; it keeps state in @next_input_prefill and
20
+ # reads @no_default_input.
21
+ module InputSupport
22
+ PROMPT_HISTORY_ENV = "SAMAGOTCHI_HISTORY_FILE"
23
+ XDG_STATE_HOME_ENV = "XDG_STATE_HOME"
24
+ PROMPT_HISTORY_FILE = "history.json"
25
+ PROMPT_HISTORY_STATE_DIR = "samagotchi"
26
+ PROMPT_HISTORY_LIMIT = 20
27
+ DEFAULT_INPUT_ENV = "SAMAGOTCHI_DEFAULT_INPUT"
28
+ AT_PATH_COMPLETION_PREFIX = "@"
29
+ MEMORY_COMPLETION_PREFIX = "#"
30
+ AT_PATH_COMPLETION_MAX_CANDIDATES = 200
31
+
32
+ private
33
+
34
+ # The /commands Tab offers: the session's registry, the REPL's own
35
+ # among them.
36
+ def slash_commands = command_registry.completions(:repl)
37
+
38
+ def command_registry = @commands&.registry || SessionCommands.builtin_registry
39
+
40
+ # Whether a new session's first read gets the default input
41
+ # (SAMAGOTCHI_DEFAULT_INPUT); --no-default-input says no.
42
+ def default_input_wanted? = !@no_default_input
43
+
44
+ # One read at the main prompt. In multiline mode Enter submits, while
45
+ # Meta+Enter/Alt+Enter inserts a newline on terminals that emit that
46
+ # distinct sequence (for example kitty); Tab completes; a queued prefill
47
+ # is typed in first.
48
+ # @return [String, nil] the line, nil on Ctrl-D
49
+ def read_prompt_line(prompt)
50
+ input = with_scoped_at_path_completion do
51
+ with_next_input_prefill do
52
+ Reline.readmultiline(prompt, true) { true }
53
+ end
54
+ end
55
+ return nil if input.nil?
56
+
57
+ input.gsub(/\r\n?|\n\z/, "\n").strip
58
+ end
59
+
60
+ def with_scoped_at_path_completion
61
+ previous_completion_proc = Reline.completion_proc
62
+ previous_autocompletion = Reline.autocompletion
63
+ Reline.autocompletion = true
64
+ Reline.completion_proc = method(:assist_path_completion_candidates).to_proc
65
+ yield
66
+ ensure
67
+ Reline.completion_proc = previous_completion_proc
68
+ Reline.autocompletion = previous_autocompletion
69
+ end
70
+
71
+ def assist_path_completion_candidates(word)
72
+ token = word.to_s
73
+ return [] if token.empty?
74
+
75
+ if token.start_with?("/")
76
+ return build_slash_completion_candidates(token)
77
+ end
78
+
79
+ if token.start_with?(AT_PATH_COMPLETION_PREFIX)
80
+ path_fragment = token.delete_prefix(AT_PATH_COMPLETION_PREFIX)
81
+ return build_project_path_completion_candidates(path_fragment)
82
+ end
83
+
84
+ if token.start_with?(MEMORY_COMPLETION_PREFIX)
85
+ memory_fragment = token.delete_prefix(MEMORY_COMPLETION_PREFIX)
86
+ return build_memory_completion_candidates(memory_fragment)
87
+ end
88
+
89
+ []
90
+ end
91
+
92
+ def build_slash_completion_candidates(slash_fragment)
93
+ fragment = slash_fragment.to_s.strip
94
+ return [] unless fragment.start_with?("/")
95
+
96
+ begin
97
+ buf = Reline.line_buffer.to_s
98
+ unless buf.empty?
99
+ return [] unless buf.lstrip.start_with?("/")
100
+ end
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ lowered = fragment.downcase
106
+ return slash_commands.dup if lowered == "/"
107
+
108
+ slash_commands.select { |cmd| cmd.start_with?(lowered) }
109
+ rescue StandardError
110
+ []
111
+ end
112
+
113
+ def build_project_path_completion_candidates(path_fragment)
114
+ fragment = path_fragment.to_s.tr("\\", "/")
115
+ return [] if fragment.start_with?("/")
116
+ return [] if fragment.split("/").include?("..")
117
+
118
+ dir_part = ""
119
+ entry_prefix = fragment
120
+
121
+ if fragment.include?("/")
122
+ dir_part = fragment.sub(%r{[^/]*\z}, "")
123
+ entry_prefix = fragment.split("/").last.to_s
124
+ end
125
+
126
+ base_dir = dir_part.empty? ? Dir.pwd : File.expand_path(dir_part, Dir.pwd)
127
+ return [] unless path_within_cwd?(base_dir)
128
+ return [] unless File.directory?(base_dir)
129
+
130
+ entries = Dir.children(base_dir).sort
131
+ entries.reject! { |entry| entry.start_with?(".") } unless entry_prefix.start_with?(".")
132
+ matches = entries.select { |entry| entry.start_with?(entry_prefix) }
133
+
134
+ matches.first(AT_PATH_COMPLETION_MAX_CANDIDATES).map do |entry|
135
+ relative_path = "#{dir_part}#{entry}".tr("\\", "/")
136
+ absolute_path = File.join(base_dir, entry)
137
+ relative_path = "#{relative_path}/" if File.directory?(absolute_path)
138
+ "#{AT_PATH_COMPLETION_PREFIX}#{relative_path}"
139
+ end
140
+ rescue StandardError
141
+ []
142
+ end
143
+
144
+ def path_within_cwd?(path)
145
+ expanded = File.expand_path(path)
146
+ cwd = Dir.pwd
147
+ expanded == cwd || expanded.start_with?("#{cwd}#{File::SEPARATOR}")
148
+ end
149
+
150
+ def build_memory_completion_candidates(memory_fragment)
151
+ fragment = memory_fragment.to_s.strip.tr("\\", "/")
152
+ candidates = memory_completion_entries
153
+ return candidates.map { |entry| entry[:token] } if fragment.empty?
154
+
155
+ candidates.filter_map do |entry|
156
+ entry[:token] if entry[:token].delete_prefix(MEMORY_COMPLETION_PREFIX).start_with?(fragment)
157
+ end
158
+ end
159
+
160
+ def memory_completion_entries
161
+ grouped = Hash.new { |hash, key| hash[key] = [] }
162
+
163
+ each_memory_completion_entry do |scope, name|
164
+ grouped[name] << scope unless grouped[name].include?(scope)
165
+ end
166
+
167
+ grouped.sort_by do |name, scopes|
168
+ [memory_scope_sort_key(scopes.min_by { |scope| memory_scope_sort_key(scope) }), name]
169
+ end.flat_map do |name, scopes|
170
+ scopes = scopes.sort_by { |scope| [memory_scope_sort_key(scope), scope] }
171
+ if scopes.length == 1
172
+ [{ token: "#{MEMORY_COMPLETION_PREFIX}#{name}", scope: scopes.first, name: name }]
173
+ else
174
+ scopes.map do |scope|
175
+ { token: "#{MEMORY_COMPLETION_PREFIX}#{scope}/#{name}", scope: scope, name: name }
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ def memory_scope_sort_key(scope)
182
+ scope == "project" ? 0 : 1
183
+ end
184
+
185
+ def each_memory_completion_entry
186
+ memory_completion_dirs.each do |scope, dir|
187
+ next unless File.directory?(dir)
188
+
189
+ Dir.glob(File.join(dir, "*.md")).sort.each do |path|
190
+ name = File.basename(path, ".md")
191
+ next if name.empty? || name == Tools::MEMORY_INDEX
192
+
193
+ yield scope, name
194
+ end
195
+ end
196
+ rescue StandardError
197
+ []
198
+ end
199
+
200
+ def memory_completion_dirs
201
+ {
202
+ "project" => File.expand_path(MemoryPaths.project_dir, Dir.pwd),
203
+ "system" => File.expand_path(MemoryPaths.system_dir)
204
+ }
205
+ end
206
+
207
+ def normalize_model_input(input)
208
+ input.to_s.gsub(/(^|[^\w\/])#((?:project|system)\/)?([a-zA-Z0-9][a-zA-Z0-9_-]*)/) do
209
+ prefix = Regexp.last_match(1)
210
+ scoped = Regexp.last_match(2).to_s
211
+ name = Regexp.last_match(3)
212
+ scope = scoped.delete_suffix("/")
213
+ normalized = if scope.empty?
214
+ "memory \"#{name}\""
215
+ else
216
+ "memory \"#{name}\" in #{scope} scope"
217
+ end
218
+ "#{prefix}#{normalized}"
219
+ end
220
+ end
221
+
222
+ def history_file_path
223
+ explicit = ENV[PROMPT_HISTORY_ENV].to_s.strip
224
+ return explicit unless explicit.empty?
225
+
226
+ File.join(xdg_state_home, PROMPT_HISTORY_STATE_DIR, PROMPT_HISTORY_FILE)
227
+ end
228
+
229
+ def xdg_state_home
230
+ configured = ENV[XDG_STATE_HOME_ENV].to_s.strip
231
+ return configured unless configured.empty?
232
+
233
+ File.join(Dir.home, ".local", "state")
234
+ end
235
+
236
+ def load_persistent_history
237
+ entries = load_history_entries_from_disk
238
+ entries.last(PROMPT_HISTORY_LIMIT).each { |entry| Reline::HISTORY << entry }
239
+ rescue StandardError
240
+ nil
241
+ end
242
+
243
+ def persist_recent_history(input)
244
+ entries = normalize_history_entries(load_history_entries_from_disk)
245
+ entries << input
246
+ trimmed_entries = entries.last(PROMPT_HISTORY_LIMIT)
247
+ path = history_file_path
248
+ FileUtils.mkdir_p(File.dirname(path))
249
+ File.write(path, JSON.pretty_generate(trimmed_entries) + "\n")
250
+ rescue StandardError
251
+ nil
252
+ end
253
+
254
+ def load_history_entries_from_disk
255
+ path = history_file_path
256
+ return [] unless File.file?(path)
257
+
258
+ raw = File.read(path)
259
+ parsed = JSON.parse(raw)
260
+ normalize_history_entries(parsed)
261
+ rescue JSON::ParserError
262
+ normalize_history_entries(raw.to_s.lines.map(&:chomp))
263
+ rescue StandardError
264
+ []
265
+ end
266
+
267
+ def normalize_history_entries(entries)
268
+ Array(entries).map { |entry| entry.to_s.gsub(/\r\n?/, "\n").strip }.reject(&:empty?)
269
+ end
270
+
271
+ # The text as given ("Please " keeps its space); a blank one is none.
272
+ def queue_input_prefill(text)
273
+ return if text.to_s.strip.empty?
274
+
275
+ @next_input_prefill = text.to_s
276
+ end
277
+
278
+ def queue_default_input
279
+ default = default_input_text
280
+ queue_input_prefill(default) if default
281
+ end
282
+
283
+ # @return [String, nil] the default input for a new session's first
284
+ # read, if it gets one
285
+ def default_input_text
286
+ return nil unless default_input_wanted?
287
+
288
+ default = ENV.fetch(DEFAULT_INPUT_ENV, nil)
289
+ return nil if default.nil? || default.strip.empty?
290
+
291
+ default
292
+ end
293
+
294
+ def consume_input_prefill
295
+ value = @next_input_prefill
296
+ @next_input_prefill = nil
297
+ value
298
+ end
299
+
300
+ def with_next_input_prefill
301
+ prefill = consume_input_prefill
302
+ return yield if prefill.nil? || prefill.empty?
303
+
304
+ previous_hook = Reline.pre_input_hook
305
+ inserted = false
306
+ Reline.pre_input_hook = proc do
307
+ unless inserted
308
+ Reline.insert_text(prefill)
309
+ inserted = true
310
+ end
311
+ previous_hook.call if previous_hook
312
+ end
313
+ begin
314
+ yield
315
+ ensure
316
+ # Restore only what we replaced: a method-level ensure also ran on the
317
+ # no-prefill early return and reset the hook to nil, dropping the
318
+ # Engine activity hook (with_activity_hook) after the first prompt.
319
+ Reline.pre_input_hook = previous_hook
320
+ end
321
+ end
322
+ end
323
+ end
324
+ end