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,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "output_formatter"
4
+ require_relative "turn_note"
5
+ require_relative "context_note"
6
+
7
+ module Samagotchi
8
+ # The state around turns that the REPL and a session worker share, kept out
9
+ # of either loop: the pre-turn checkpoint (what !rollback and a failed turn
10
+ # restore), the continue offer after a turn that ran out of iterations, and
11
+ # the checkpoint a continue turn starts from.
12
+ #
13
+ # It edits the conversation only through the Engine's out-of-turn messages
14
+ # API (messages_checkpoint / rollback_to / append_messages); saving the
15
+ # session and telling the user are the host's.
16
+ class TurnFlow
17
+ CONTINUE_COMMAND = "/continue"
18
+ SUMMARY_PROMPT_LIMIT = 600
19
+ SUMMARY_MODEL_LIMIT = 360
20
+ SUMMARY_PARAMS_LIMIT = 80
21
+ SUMMARY_TOOLS_LIMIT = 5
22
+
23
+ # Read an answer to the continue offer.
24
+ # @return [Array(Symbol, String|nil)] [:resume | :abort | :abort_with_reason | :invalid, reason]
25
+ def self.continue_decision(input)
26
+ normalized = input.to_s.strip
27
+ return [:resume, nil] if normalized.empty?
28
+
29
+ lowered = normalized.downcase
30
+ return [:resume, nil] if lowered == CONTINUE_COMMAND || lowered == "yes" || lowered == "y"
31
+ return [:abort, nil] if lowered == "no" || lowered == "n"
32
+
33
+ reason_match = normalized.match(/\A(?:no|n)\s*[,:\-]\s*(.+)\z/i)
34
+ if reason_match
35
+ reason = reason_match[1].to_s.strip
36
+ return [:abort, nil] if reason.empty?
37
+
38
+ return [:abort_with_reason, reason]
39
+ end
40
+
41
+ [:invalid, nil]
42
+ end
43
+
44
+ # @return [Hash, nil] the pending continue offer: {context:, no_interrupt:}
45
+ # (context summarizes the interrupted turn for a "no, <reason>")
46
+ attr_reader :offer
47
+
48
+ def initialize(engine:)
49
+ @engine = engine
50
+ @checkpoint = nil
51
+ @continue_checkpoint = nil
52
+ @offer = nil
53
+ end
54
+
55
+ def awaiting_continue? = !@offer.nil?
56
+
57
+ # A prompt turn is about to start: remember the conversation before it.
58
+ def before_prompt_turn
59
+ @checkpoint = @engine.messages_checkpoint
60
+ end
61
+
62
+ # A continue turn is about to start: a cancel goes back to here.
63
+ def before_continue_turn
64
+ @continue_checkpoint = @engine.messages_checkpoint
65
+ end
66
+
67
+ # Take a turn's result.
68
+ # @param continue [Boolean] it was a continue turn
69
+ # @param no_interrupt [Boolean] the turn ran with no iteration limit to
70
+ # speak of; an offer it makes keeps that for its continue turn
71
+ # @return [Symbol] :completed, :continue_offered, :cancelled (a prompt
72
+ # turn: its partial progress stays, !rollback can undo it) or
73
+ # :continue_cancelled (back to before the continue; the offer stays)
74
+ def after_turn(result, continue: false, no_interrupt: false)
75
+ if result.respond_to?(:canceled?) && result.canceled?
76
+ if continue
77
+ restore(@continue_checkpoint)
78
+ return :continue_cancelled
79
+ end
80
+
81
+ # The kernel salvaged the completed tool calls and the partial reply
82
+ # into the conversation; without one, the turn leaves nothing.
83
+ restore(@checkpoint) if @checkpoint && !conversation_of(result)
84
+ @offer = nil
85
+ return :cancelled
86
+ end
87
+
88
+ if result.respond_to?(:resumable?) && result.resumable?
89
+ @offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
90
+ :continue_offered
91
+ else
92
+ @offer = nil
93
+ @checkpoint = nil
94
+ :completed
95
+ end
96
+ end
97
+
98
+ # A prompt turn failed: back to the conversation before it, plus the
99
+ # note that says so (TurnNote.failed) in place of an older one at the
100
+ # tail, so the model reads why its last message went unanswered.
101
+ def prompt_turn_failed(note: nil)
102
+ if @checkpoint
103
+ restore(@checkpoint, note: note)
104
+ elsif note
105
+ @engine.rollback_to(TurnNote.replace_trailing(@engine.messages_checkpoint, note))
106
+ end
107
+ @offer = nil
108
+ @checkpoint = nil
109
+ end
110
+
111
+ # Answer the offer with no: the interrupted turn is discarded, and a
112
+ # reason (with a summary of that turn) is left for the model to read.
113
+ def abort_continue!(reason: nil)
114
+ restore(@checkpoint) if @checkpoint
115
+ @checkpoint = nil
116
+ @engine.append_messages([{ role: "user", content: reason_message(reason) }]) if reason
117
+ @offer = nil
118
+ end
119
+
120
+ # A new prompt came instead of an answer (a worker takes it): the offer
121
+ # is gone and the partial turn stays, as after a Ctrl-C.
122
+ def drop_offer!
123
+ @offer = nil
124
+ end
125
+
126
+ # Restore the pre-turn checkpoint (!rollback after a Ctrl-C). A pending
127
+ # offer goes too: the turn it offered to continue is gone. (The REPL
128
+ # reads every line as an answer while an offer is pending; a worker
129
+ # also takes !rollback then.)
130
+ # @return [Boolean] false when there is none
131
+ def rollback!
132
+ return false unless @checkpoint
133
+
134
+ restore(@checkpoint)
135
+ @checkpoint = nil
136
+ @offer = nil
137
+ true
138
+ end
139
+
140
+ # The conversation changed outside a turn (!cmd output): rolling back
141
+ # past that would silently drop it.
142
+ def note_conversation_changed
143
+ @checkpoint = nil
144
+ end
145
+
146
+ # A reminder turn ran. The checkpoint stays only for a pending offer.
147
+ def after_reminder_turn
148
+ @checkpoint = nil unless awaiting_continue?
149
+ end
150
+
151
+ private
152
+
153
+ # Back to +checkpoint+, keeping the context notes that arrived since:
154
+ # notes land between turns, after the checkpoint was taken, and a
155
+ # rollback must not drop them. One rollback, +note+ included.
156
+ def restore(checkpoint, note: nil)
157
+ kept = Array(checkpoint).filter_map { |m| m[:note_id] }
158
+ arrived = @engine.messages_checkpoint.select { |m| ContextNote.note?(m) && !kept.include?(m[:note_id]) }
159
+ restored = Array(checkpoint) + arrived
160
+ restored = TurnNote.replace_trailing(restored, note) if note
161
+ @engine.rollback_to(restored)
162
+ end
163
+
164
+ def conversation_of(result)
165
+ result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
166
+ end
167
+
168
+ def interrupted_turn_context(result)
169
+ messages = interrupted_turn_messages(conversation_of(result))
170
+ {
171
+ original_prompt: preview_text(messages.find { |m| m[:role] == "user" }&.dig(:content), SUMMARY_PROMPT_LIMIT),
172
+ tool_trace: tool_trace(result),
173
+ last_model_intent: preview_text(model_intent(messages.reverse.find { |m| m[:role] == "model" }&.dig(:content)), SUMMARY_MODEL_LIMIT)
174
+ }
175
+ end
176
+
177
+ # The messages the turn added after the checkpoint (all of them after an
178
+ # empty one: a new session's first turn). The prefix is matched by role,
179
+ # not content: the kernel returns earlier model messages without their
180
+ # thinking, so they differ from the saved ones, one for one.
181
+ def interrupted_turn_messages(conversation)
182
+ checkpoint = Array(@checkpoint)
183
+ conversation = Array(conversation)
184
+ return [] if conversation.length < checkpoint.length
185
+ return [] unless conversation.first(checkpoint.length).map { |m| m[:role] } == checkpoint.map { |m| m[:role] }
186
+
187
+ conversation[checkpoint.length..] || []
188
+ end
189
+
190
+ def tool_trace(result)
191
+ activities = result.respond_to?(:tool_activity) ? Array(result.tool_activity) : []
192
+ activities.last(SUMMARY_TOOLS_LIMIT).map do |activity|
193
+ tool = activity[:tool].to_s.strip
194
+ status = activity[:status].to_s.strip
195
+ params = preview_text(activity[:params], SUMMARY_PARAMS_LIMIT)
196
+ parts = [tool]
197
+ parts << "status=#{status}" unless status.empty?
198
+ parts << "params=#{params}" unless params.empty?
199
+ parts.join(" ")
200
+ end
201
+ end
202
+
203
+ def reason_message(reason)
204
+ context = @offer && @offer[:context]
205
+ lines = ["I chose not to continue the interrupted turn because: #{reason}"]
206
+ lines << ""
207
+ lines << "Interrupted turn summary:"
208
+ original_prompt = context && context[:original_prompt]
209
+ lines << "- original_prompt: #{original_prompt.to_s.empty? ? "(unavailable)" : original_prompt}"
210
+
211
+ tool_trace = context ? Array(context[:tool_trace]) : []
212
+ if tool_trace.empty?
213
+ lines << "- interrupted_tools: (none)"
214
+ else
215
+ lines << "- interrupted_tools: #{tool_trace.join("; ")}"
216
+ end
217
+
218
+ model_intent = context && context[:last_model_intent]
219
+ lines << "- last_model_intent: #{model_intent.to_s.empty? ? "(unavailable)" : model_intent}"
220
+ lines << ""
221
+ lines << "Please keep the original prompt context. If my next message does not provide a clear replacement request, ask what we should do instead."
222
+ lines.join("\n")
223
+ end
224
+
225
+ # The model's words without its thinking and tool-call markup; when it
226
+ # wrote nothing else, its thinking says what it was about to do.
227
+ def model_intent(content)
228
+ text = OutputFormatter.strip(content)
229
+ return text unless text.empty?
230
+
231
+ OutputFormatter.strip(content.to_s.gsub(%r{</?think>}, ""))
232
+ end
233
+
234
+ def preview_text(text, limit)
235
+ normalized = text.to_s.gsub(/\s+/, " ").strip
236
+ return "" if normalized.empty?
237
+ return normalized if normalized.length <= limit
238
+
239
+ normalized[0, limit].rstrip + "..."
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # The one-line system note a turn leaves in the conversation when it ends
5
+ # without an answer: failed before the model replied, cancelled, or over
6
+ # with nothing visible. The UIs show these ends live (`turn failed …`, a
7
+ # cancel line, `(the model returned an empty answer)`), but the model saw
8
+ # none of that on its next turn: the session held an unanswered user
9
+ # message and no reason. A tail system message, like a reminder or a
10
+ # context note, keeps the prompt cache and is hidden by the UIs (they
11
+ # show system messages only when `kind: note`).
12
+ module TurnNote
13
+ KIND = "turn_note"
14
+ OPEN = "[SYSTEM: "
15
+ CLOSE = "]"
16
+
17
+ module_function
18
+
19
+ # @param summary [String] the error's one-line summary
20
+ # @param restored [Boolean] the prompt went back to its sender (the
21
+ # failed user message is not in the conversation any more)
22
+ # @param continued [Boolean] it was a continue turn (no user message)
23
+ def failed(summary, restored: false, continued: false)
24
+ tail = if restored then "The message went back to the user, who may send it again."
25
+ elsif continued then "The continued turn stopped there."
26
+ else "The user's last message was not answered."
27
+ end
28
+ message("the previous turn failed before any answer: #{one_line(summary)}. #{tail}")
29
+ end
30
+
31
+ # @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
32
+ # @param seconds [Numeric, nil] how long the turn had run
33
+ # @param shown [Boolean] visible text had streamed (an `[interrupted]`
34
+ # model message precedes this note)
35
+ def cancelled(reason, seconds: nil, shown: false)
36
+ why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")})"
37
+ after = seconds ? " after #{seconds.round}s" : ""
38
+ what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
39
+ message("the previous turn was cancelled#{why}#{after}; #{what}")
40
+ end
41
+
42
+ def empty
43
+ message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
44
+ end
45
+
46
+ def note?(entry)
47
+ return false unless entry.respond_to?(:[])
48
+
49
+ (entry[:kind] || entry["kind"]).to_s == KIND
50
+ end
51
+
52
+ def message(text)
53
+ { role: "system", content: "#{OPEN}#{text}#{CLOSE}", kind: KIND }
54
+ end
55
+
56
+ # +messages+ plus +note+, in place of a note already at the tail (behind
57
+ # context notes at most): failed retries leave one note, not a pile.
58
+ def replace_trailing(messages, note)
59
+ list = Array(messages).dup
60
+ index = list.length - 1
61
+ index -= 1 while index >= 0 && (list[index][:role] || list[index]["role"]).to_s == "system" && !note?(list[index])
62
+ list.delete_at(index) if index >= 0 && note?(list[index])
63
+ list << note
64
+ end
65
+
66
+ # The last message is a reply cut short (`[interrupted]`).
67
+ def interrupted_tail?(messages)
68
+ last = Array(messages).last
69
+ !!(last && (last[:interrupted] || last["interrupted"]))
70
+ end
71
+
72
+ def one_line(text)
73
+ text.to_s.gsub(/\s+/, " ").strip
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # A running count of one turn's tool calls, for the one-line tally a long,
5
+ # tool-heavy turn shows under its activity row:
6
+ #
7
+ # 12 tool calls (2 failed) · execute ×7 · read_file ×3 · edit ×2 · last: execute command=rspec
8
+ #
9
+ # Built from the turn's :tool_call_started / :tool_call_completed events
10
+ # (or a joined turn's snapshot parts), with no model call. The web builds
11
+ # the same text from its activity rows (public/tally.js); both follow
12
+ # spec/shared/tally_matrix.json.
13
+ class TurnTally
14
+ # Below this many calls the "running <tool>…" row says enough.
15
+ MIN_CALLS = 3
16
+ TOP_TOOLS = 3
17
+ SEPARATOR = " · "
18
+
19
+ def initialize
20
+ reset
21
+ end
22
+
23
+ # @return [TurnTally] self
24
+ def reset
25
+ @calls = {}
26
+ self
27
+ end
28
+
29
+ def count
30
+ @calls.size
31
+ end
32
+
33
+ # @param key [Object] one call's identity within the turn (the event's
34
+ # [iteration, call_index])
35
+ def started(key:, tool:, params: nil)
36
+ call = (@calls[key] ||= { tool: tool.to_s, params: "", status: "running" })
37
+ call[:tool] = tool.to_s unless tool.to_s.empty?
38
+ call[:params] = params.to_s unless params.nil?
39
+ call[:status] = "running"
40
+ call
41
+ end
42
+
43
+ # A completion with no start seen still counts as a call (as the web's
44
+ # activity rows do). Only "error" is a failure: a call a guardrail or an
45
+ # approval blocked still counts as a call, not as a failed one.
46
+ def completed(key:, tool:, status:, params: nil)
47
+ call = @calls[key] || started(key: key, tool: tool, params: params)
48
+ call[:params] = params.to_s if params
49
+ call[:status] = status.to_s == "error" ? "error" : "ok"
50
+ call
51
+ end
52
+
53
+ # Pick up a turn joined mid-way: its snapshot's tool parts.
54
+ # @param parts [Array<Hash>] the Bridge snapshot's current_turn parts
55
+ def seed(parts)
56
+ Array(parts).each do |part|
57
+ part = part.transform_keys(&:to_sym)
58
+ next unless part[:kind].to_s == "tool"
59
+
60
+ key = [part[:iteration].to_i, part[:call_index].to_i]
61
+ started(key: key, tool: part[:tool], params: part[:params])
62
+ completed(key: key, tool: part[:tool], status: part[:status]) unless part[:status].to_s == "running"
63
+ end
64
+ self
65
+ end
66
+
67
+ # @param width [Integer, nil] cut the text to this many characters
68
+ # @param last [Boolean] end with the last call and its params
69
+ # @return [String, nil] nil below MIN_CALLS
70
+ def text(width: nil, last: true)
71
+ self.class.format(@calls.values, last: last, width: width)
72
+ end
73
+
74
+ # @param calls [Array<Hash>] {tool:, params:, status:} in call order
75
+ def self.format(calls, last: true, width: nil)
76
+ return nil if calls.size < MIN_CALLS
77
+
78
+ failed = calls.count { |c| c[:status] == "error" }
79
+ head = "#{calls.size} tool calls"
80
+ head += " (#{failed} failed)" if failed.positive?
81
+ counts = calls.each_with_object(Hash.new(0)) { |c, h| h[c[:tool]] += 1 }
82
+ # Ties go to the tool used first (a Hash keeps insertion order, and
83
+ # sort_by is not stable, so the index breaks them).
84
+ top = counts.each_with_index.sort_by { |(_, n), i| [-n, i] }.first(TOP_TOOLS)
85
+ fields = [head] + top.map { |(tool, n), _| "#{tool} ×#{n}" }
86
+ if last
87
+ call = calls.last
88
+ params = call[:params].to_s.gsub(/\s+/, " ").strip
89
+ fields << "last: #{[call[:tool], params].reject(&:empty?).join(" ")}"
90
+ end
91
+ cut(fields.join(SEPARATOR), width)
92
+ end
93
+
94
+ def self.cut(text, width)
95
+ return text if width.nil? || text.length <= width
96
+ return text[0, width] if width < 2
97
+
98
+ "#{text[0, width - 1]}…"
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ VERSION = "0.2.0"
5
+ # Sent as the User-Agent of every HTTP request chi makes.
6
+ USER_AGENT = "chi/#{VERSION}"
7
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "image_store"
5
+
6
+ module Samagotchi
7
+ # What a turn's loops need to send images, set on the kernel by the
8
+ # Engine for one turn:
9
+ # capability VisionSupport::Answer (nil: unknown, send), or a callable
10
+ # that answers it on first need (a probe only when a turn
11
+ # has images)
12
+ # session_dir where the session's images/ are (refs resolve against it)
13
+ # limits ImageStore::Limits
14
+ # resizer ImageResizer for images a tool returns (nil: detect)
15
+ class VisionContext
16
+ attr_reader :session_dir, :limits, :resizer
17
+
18
+ def initialize(capability: nil, session_dir: nil, limits: ImageStore::Limits.from_config, resizer: nil)
19
+ @capability = capability
20
+ @session_dir = session_dir
21
+ @limits = limits
22
+ @resizer = resizer
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ def capability
27
+ @mutex.synchronize do
28
+ @capability = @capability.call if @capability.respond_to?(:call)
29
+ @capability
30
+ end
31
+ end
32
+
33
+ def with(**changes)
34
+ self.class.new(capability: @capability, session_dir: session_dir, limits: limits, resizer: resizer, **changes)
35
+ end
36
+
37
+ # False only when the model is known not to see images.
38
+ def sendable? = capability.nil? || capability.value != false
39
+
40
+ # Why images aren't sent, for a line or a refusal.
41
+ def refusal_reason = capability&.reason || ImagePlan::CANT_SEE
42
+
43
+ # The ref's base64, or nil when it isn't a valid ref of this session.
44
+ def base64(ref)
45
+ return nil unless session_dir && ImageStore.valid_ref?(session_dir, ref)
46
+
47
+ ImageStore.base64(session_dir, ref)
48
+ rescue StandardError
49
+ nil
50
+ end
51
+
52
+ def data_uri(ref)
53
+ data = base64(ref)
54
+ data && "data:#{ref[:mime] || ref["mime"]};base64,#{data}"
55
+ end
56
+
57
+ # Stores an image a tool read or returned (source "tool", from a +path+
58
+ # or raw +bytes+) and returns its ref.
59
+ def ingest(path = nil, name: nil, bytes: nil)
60
+ raise ImageStore::Error, "no session to keep the image in" unless session_dir
61
+
62
+ ImageStore.ingest(session_dir, path: path, bytes: bytes, name: name, source: "tool", limits: limits,
63
+ resizer: resizer || ImageResizer.detect)
64
+ end
65
+ end
66
+
67
+ # Which images of a conversation one request sends. Every image that is
68
+ # not sent becomes a placeholder line: all of them when the model can't
69
+ # see images, the older ones past limits.max_per_request (the newest go),
70
+ # and any whose file is gone.
71
+ class ImagePlan
72
+ Item = Data.define(:ref, :data, :placeholder) do
73
+ def sent? = !data.nil?
74
+ end
75
+
76
+ ROLES = %w[user tool_response].freeze
77
+ CANT_SEE = "this model can't see images"
78
+ # Stands for the server's media marker in a native prompt until
79
+ # Client#complete swaps in the live one (random, so no typed text has it).
80
+ NATIVE_PLACEHOLDER = "<__chi_image_#{SecureRandom.hex(8)}__>".freeze
81
+
82
+ # @param conversation [Array<Hash>] engine-format messages
83
+ # @param vision [VisionContext, nil]
84
+ # @param data [Symbol] :base64 (native) or :data_uri (chat)
85
+ def initialize(conversation, vision, data: :data_uri)
86
+ @vision = vision
87
+ @data = data
88
+ occurrences = []
89
+ conversation.each_with_index do |entry, index|
90
+ next unless ROLES.include?(entry[:role].to_s)
91
+
92
+ Array(entry[:images]).each_index { |position| occurrences << [index, position] }
93
+ end
94
+ @limit = vision&.limits&.max_per_request || ImageStore::Limits.from_config.max_per_request
95
+ @sent = occurrences.last(@limit)
96
+ end
97
+
98
+ # The Items for conversation[+index+] (none when it has no images).
99
+ def items(entry, index)
100
+ return [] unless ROLES.include?(entry[:role].to_s)
101
+
102
+ Array(entry[:images]).each_with_index.map do |raw, position|
103
+ ref = ImageStore.symbolize(raw)
104
+ reason = skip_reason(index, position)
105
+ data = reason ? nil : fetch(ref)
106
+ reason ||= "the image file is missing" unless data
107
+ Item.new(ref: ref, data: data, placeholder: reason && ImageRef.placeholder(ref, reason))
108
+ end
109
+ end
110
+
111
+ # Estimated tokens of the images a request sends (ImageRef.estimated_tokens).
112
+ def self.estimated_tokens(conversation)
113
+ conversation.sum do |entry|
114
+ ROLES.include?(entry[:role].to_s) ? Array(entry[:images]).sum { |ref| ImageRef.estimated_tokens(ImageStore.symbolize(ref)) } : 0
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def skip_reason(index, position)
121
+ return "no session images here" if @vision.nil?
122
+ return CANT_SEE unless @vision.sendable?
123
+ return "only the newest #{@limit} images are sent" unless @sent.include?([index, position])
124
+
125
+ nil
126
+ end
127
+
128
+ def fetch(ref)
129
+ @data == :base64 ? @vision.base64(ref) : @vision.data_uri(ref)
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config"
4
+
5
+ module Samagotchi
6
+ # Whether a model can take images, asked before a turn with images is
7
+ # sent: true (send), false (refuse, with a reason), or nil (unknown: send,
8
+ # and map the provider's error if it says no).
9
+ #
10
+ # First match wins:
11
+ # 1. config: models.<name>.vision, then hosts.<name>.vision
12
+ # 2. a native host: llama.cpp only, and it must give a media marker, have
13
+ # vision in /props modalities, and use the profile's image template
14
+ # (the prompt can't carry an image any other way)
15
+ # 3. a chat host: a local llama.cpp's /props modalities, then the host's
16
+ # model list (input_modalities / "multimodal")
17
+ # 4. unknown
18
+ module VisionSupport
19
+ Answer = Data.define(:value, :reason) do
20
+ def yes? = value == true
21
+ def no? = value == false
22
+ end
23
+
24
+ # @param target [HostRegistry::ModelTarget]
25
+ # @param profile [ModelProfile, nil] the model's prompt profile (native)
26
+ # @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
27
+ # @param models [Hash, nil] ConfigFile.model_settings (specs)
28
+ # @return [Answer]
29
+ def self.for(target, profile: nil, adapter: nil, models: nil)
30
+ entry = target.entry
31
+ configured = configured(target, entry, models)
32
+ return configured if configured&.no?
33
+
34
+ if entry.chat?
35
+ configured || chat(target, entry, adapter)
36
+ else
37
+ native(target, entry, profile, trust_config: configured&.yes?)
38
+ end
39
+ end
40
+
41
+ # The media marker the running llama.cpp wants in the prompt, or nil.
42
+ def self.media_marker(props)
43
+ marker = props&.answered? && props.body.is_a?(Hash) ? props.body["media_marker"] : nil
44
+ marker.is_a?(String) && !marker.empty? ? marker : nil
45
+ end
46
+
47
+ def self.configured(target, entry, models)
48
+ models ||= begin
49
+ ConfigFile.model_settings
50
+ rescue StandardError
51
+ {}
52
+ end
53
+ [target.model, target.bare_model].map { |name| name.to_s.strip.downcase }.uniq.each do |key|
54
+ value = models.dig(key, :vision)
55
+ next if value.nil?
56
+
57
+ return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false")
58
+ end
59
+ return nil if entry.vision.nil?
60
+
61
+ Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
62
+ end
63
+
64
+ def self.native(target, entry, profile, trust_config:)
65
+ client = target.client
66
+ transport = client.respond_to?(:transport) ? client.transport.name : :llama_cpp
67
+ return no("#{transport} hosts take no images (only llama.cpp does)") unless transport == :llama_cpp
68
+
69
+ props = client.respond_to?(:server_props) ? client.server_props(model: target.bare_model) : nil
70
+ return no("can't reach /props for the media marker") unless props&.answered?
71
+
72
+ body = props.body.is_a?(Hash) ? props.body : {}
73
+ unless trust_config || body.dig("modalities", "vision") == true
74
+ return no("the server has no vision model loaded (start llama.cpp with --mmproj)")
75
+ end
76
+ return no("the server gives no media marker (update llama.cpp)") unless media_marker(props)
77
+ return no("profile #{profile&.name || "?"} has no image template yet") unless profile&.image_template
78
+
79
+ open = profile.image_open_token
80
+ unless open.empty? || body["chat_template"].to_s.include?(open)
81
+ return no("the server's chat template doesn't use #{open} (profile #{profile.name})")
82
+ end
83
+
84
+ Answer.new(value: true, reason: nil)
85
+ end
86
+
87
+ def self.chat(target, entry, adapter)
88
+ unless entry.remote?
89
+ props = entry.client.respond_to?(:server_props) ? entry.client.server_props(model: target.bare_model) : nil
90
+ if props&.answered? && props.body.is_a?(Hash) && props.body.key?("modalities")
91
+ vision = props.body.dig("modalities", "vision") == true
92
+ return vision ? Answer.new(value: true, reason: nil) : no("the server has no vision model loaded (start llama.cpp with --mmproj)")
93
+ end
94
+ end
95
+
96
+ listed = adapter.respond_to?(:image_input) ? adapter.image_input(model: target.bare_model) : nil
97
+ return Answer.new(value: true, reason: nil) if listed == true
98
+ return no("host #{entry.name} lists #{target.bare_model} as text-only") if listed == false
99
+
100
+ Answer.new(value: nil, reason: nil)
101
+ rescue StandardError
102
+ Answer.new(value: nil, reason: nil)
103
+ end
104
+
105
+ def self.no(reason) = Answer.new(value: false, reason: reason)
106
+
107
+ private_class_method :configured, :native, :chat, :no
108
+ end
109
+ end