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,277 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../tools/execute"
5
+ require_relative "../tools/read"
6
+ require_relative "../tools/write"
7
+ require_relative "../tools/memory"
8
+ require_relative "../tools/edit"
9
+ require_relative "../tools/task_create"
10
+ require_relative "../tools/task_get"
11
+ require_relative "../tools/task_list"
12
+ require_relative "../tools/task_stop"
13
+ require_relative "../tools/task_wait"
14
+ require_relative "../tools/web_fetch"
15
+ require_relative "../tools/register_reminder"
16
+ require_relative "../tools/cancel_reminder"
17
+ require_relative "../tools/list_reminders"
18
+ require_relative "../tools/list_sessions"
19
+ require_relative "../tools/send_note"
20
+ require_relative "../tools/delegate"
21
+ require_relative "../tools/delegate_result"
22
+ require_relative "../tools/ask_user_question"
23
+
24
+ module Samagotchi
25
+ module LLM
26
+ # Bridges a native tool call (an LLM::ToolCall from the chat adapter; any
27
+ # object with #name and #arguments) into samagotchi's internal tool-call shape that
28
+ # `KernelLoop#dispatch` consumes:
29
+ #
30
+ # { name:, content:, path:, scope:, start_line:, end_line:, cwd:, env:, description: }
31
+ #
32
+ # The adapter hands you a *structured* `arguments` Hash (`.arguments` is a parsed
33
+ # Hash), while the internal `content` is a per-tool serialized text blob
34
+ # (e.g. `Edit` wants "<old>…</old><new>…</new>"; `Read` wants a `path`;
35
+ # `Execute` wants the command). Rather than re-architect every tool to accept
36
+ # structured args (huge blast radius), we rebuild the exact blob per tool so
37
+ # `dispatch` and every tool stay unchanged.
38
+ #
39
+ # Unknown tool names pass through untouched so `dispatch` renders its standard
40
+ # "unknown tool … available: …" error — the loop feeds that back like any tool
41
+ # result.
42
+ #
43
+ # This is the Phase-3 crux from Spike #4: the native→internal mapping is
44
+ # independent of how tools were driven in the request body.
45
+ class NativeToolNormalizer
46
+ # Map each known tool NAME to a proc(args => internal-call-hash).
47
+ # Each proc receives the tool call's arguments (`.arguments` is a Hash of string→value)
48
+ # and returns the internal dispatch shape with every expected key present
49
+ # (missing values are nil).
50
+ #
51
+ # Args come back as strings from the wire (e.g. "start_line" => "1"); tools
52
+ # that read them do `.to_s.strip`, so we leave them as-is here and let the
53
+ # dispatch site coerce.
54
+ MAP = {
55
+ Tools::Execute::NAME => lambda { |args|
56
+ {
57
+ name: Tools::Execute::NAME,
58
+ content: args["command"].to_s,
59
+ path: nil, scope: nil,
60
+ start_line: nil, end_line: nil,
61
+ cwd: args["cwd"], env: args["env"]
62
+ }
63
+ },
64
+ Tools::Read::NAME => lambda { |args|
65
+ {
66
+ name: Tools::Read::NAME,
67
+ content: args["path"].to_s,
68
+ path: nil, scope: nil,
69
+ start_line: args["start_line"], end_line: args["end_line"],
70
+ cwd: nil, env: nil, description: nil,
71
+ args: args.is_a?(Hash) ? args : {}
72
+ }
73
+ },
74
+ Tools::Write::NAME => lambda { |args|
75
+ {
76
+ name: Tools::Write::NAME,
77
+ content: args["content"] || args["text"],
78
+ path: args["path"], scope: nil,
79
+ start_line: nil, end_line: nil,
80
+ cwd: nil, env: nil, description: nil
81
+ }
82
+ },
83
+ Tools::MemoryRead::NAME => lambda { |args|
84
+ {
85
+ name: Tools::MemoryRead::NAME,
86
+ content: args["name"].to_s,
87
+ path: nil, scope: args["scope"],
88
+ start_line: nil, end_line: nil,
89
+ cwd: nil, env: nil, description: nil
90
+ }
91
+ },
92
+ Tools::MemoryWrite::NAME => lambda { |args|
93
+ {
94
+ name: Tools::MemoryWrite::NAME,
95
+ content: args["content"] || args["text"] || args["body"],
96
+ path: args["name"], scope: args["scope"],
97
+ description: args["description"],
98
+ current_model_only: args["current_model_only"],
99
+ start_line: nil, end_line: nil,
100
+ cwd: nil, env: nil
101
+ }
102
+ },
103
+ Tools::Edit::NAME => lambda { |args|
104
+ old_text = args["old_text"] || args["old"]
105
+ new_text = args["new_text"] || args["new"]
106
+ {
107
+ name: Tools::Edit::NAME,
108
+ content: "<old>#{old_text}</old><new>#{new_text}</new>",
109
+ path: args["path"],
110
+ start_line: args["start_line"], end_line: args["end_line"],
111
+ scope: nil, cwd: nil, env: nil, description: nil
112
+ }
113
+ },
114
+ Tools::TaskCreate::NAME => lambda { |args|
115
+ {
116
+ name: Tools::TaskCreate::NAME,
117
+ content: args["command"].to_s,
118
+ path: nil, scope: nil,
119
+ start_line: nil, end_line: nil,
120
+ cwd: args["cwd"], env: args["env"], description: nil
121
+ }
122
+ },
123
+ Tools::TaskGet::NAME => lambda { |args|
124
+ {
125
+ name: Tools::TaskGet::NAME,
126
+ content: (args["id"] || args["task_id"]).to_s,
127
+ path: nil, scope: nil,
128
+ start_line: nil, end_line: nil,
129
+ cwd: nil, env: nil, description: nil
130
+ }
131
+ },
132
+ Tools::TaskStop::NAME => lambda { |args|
133
+ {
134
+ name: Tools::TaskStop::NAME,
135
+ content: (args["id"] || args["task_id"]).to_s,
136
+ path: nil, scope: nil,
137
+ start_line: nil, end_line: nil,
138
+ cwd: nil, env: nil, description: nil
139
+ }
140
+ },
141
+ Tools::TaskWait::NAME => lambda { |args|
142
+ {
143
+ name: Tools::TaskWait::NAME,
144
+ content: (args["id"] || args["task_id"]).to_s,
145
+ path: nil, scope: nil,
146
+ start_line: nil, end_line: nil,
147
+ cwd: nil, env: nil,
148
+ timeout: args["timeout"], tail_lines: args["tail_lines"], done_pattern: args["done_pattern"],
149
+ description: nil
150
+ }
151
+ },
152
+ Tools::TaskList::NAME => lambda { |_args|
153
+ {
154
+ name: Tools::TaskList::NAME,
155
+ content: "", path: nil, scope: nil,
156
+ start_line: nil, end_line: nil,
157
+ cwd: nil, env: nil, description: nil
158
+ }
159
+ },
160
+ Tools::WebFetch::NAME => lambda { |args|
161
+ {
162
+ name: Tools::WebFetch::NAME,
163
+ content: args["url"].to_s,
164
+ path: nil, scope: nil,
165
+ start_line: nil, end_line: nil,
166
+ cwd: nil, env: nil, description: nil
167
+ }
168
+ },
169
+ Tools::RegisterReminder::NAME => lambda { |args|
170
+ {
171
+ name: Tools::RegisterReminder::NAME,
172
+ content: args["name"].to_s,
173
+ path: nil, scope: nil,
174
+ description: args["description"],
175
+ interval_minutes: args["interval_minutes"]
176
+ }
177
+ },
178
+ Tools::CancelReminder::NAME => lambda { |args|
179
+ { name: Tools::CancelReminder::NAME, content: args["name"].to_s, path: nil, scope: nil }
180
+ },
181
+ Tools::ListReminders::NAME => lambda { |_args|
182
+ { name: Tools::ListReminders::NAME, content: "", path: nil, scope: nil }
183
+ },
184
+ Tools::ListSessions::NAME => lambda { |args|
185
+ { name: Tools::ListSessions::NAME, content: "", path: nil, scope: nil, cwd: args["cwd"] }
186
+ },
187
+ Tools::SendNote::NAME => lambda { |args|
188
+ { name: Tools::SendNote::NAME, content: args["text"].to_s, path: nil, scope: nil, session: args["session"].to_s }
189
+ },
190
+ Tools::Delegate::NAME => lambda { |args|
191
+ { name: Tools::Delegate::NAME, content: args["task"].to_s, path: nil, scope: nil,
192
+ model: args["model"], session: args["session"], wait: args["wait"], timeout: args["timeout"] }
193
+ },
194
+ Tools::DelegateResult::NAME => lambda { |args|
195
+ { name: Tools::DelegateResult::NAME, content: "", path: nil, scope: nil,
196
+ session: args["session"], timeout: args["timeout"] }
197
+ },
198
+ Tools::AskUserQuestion::NAME => lambda { |args|
199
+ raw_opts = args["options"]
200
+ norm_opts = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw_opts)
201
+ norm_opts = raw_opts if norm_opts.empty? && raw_opts.is_a?(Array)
202
+ {
203
+ name: Tools::AskUserQuestion::NAME,
204
+ content: args["question"].to_s,
205
+ path: nil, scope: nil,
206
+ question: args["question"].to_s,
207
+ options: norm_opts.empty? ? raw_opts : norm_opts,
208
+ header: args["header"],
209
+ multi_select: args["multi_select"],
210
+ allow_freeform: args["allow_freeform"]
211
+ }
212
+ }
213
+ }.freeze
214
+
215
+ class << self
216
+ # Map a single native tool call (LLM::ToolCall) to the internal call hash.
217
+ #
218
+ # `call.arguments` is a parsed Hash (string keys). If it is nil or a bare
219
+ # string we defensively coerce (string → JSON.parse when possible) so the
220
+ # mapping never explodes on an odd wire shape.
221
+ def normalize(call)
222
+ return nil if call.nil?
223
+
224
+ name = call.name.to_s
225
+ args = args_for(call)
226
+ proc = MAP[name]
227
+ return passthrough(name, args) unless proc
228
+
229
+ proc.call(args).merge(name: name)
230
+ end
231
+
232
+ # Map an Array of tool calls to internal call hashes (nils dropped).
233
+ def normalize_all(calls)
234
+ Array(calls).map { |call| normalize(call) }.compact
235
+ end
236
+
237
+ private
238
+
239
+ # args_for: arguments is normally a Hash. Defensively handle nil /
240
+ # JSON-string / other shapes without raising.
241
+ def args_for(call)
242
+ raw = call.respond_to?(:arguments) ? call.arguments : nil
243
+ case raw
244
+ when Hash then raw
245
+ when String
246
+ raw.strip.empty? ? {} : safe_json_parse(raw)
247
+ when nil
248
+ {}
249
+ else
250
+ { "__raw__" => raw.to_s }
251
+ end
252
+ end
253
+
254
+ def safe_json_parse(str)
255
+ JSON.parse(str)
256
+ rescue JSON::ParserError
257
+ { "__raw__" => str }
258
+ end
259
+
260
+ # A registry (plugin) tool, or an unknown one: the arguments as args:
261
+ # (typed by the schema at dispatch), plus enough for `dispatch` to
262
+ # render the standard "unknown tool" error. `content` is the joined
263
+ # argument values (harmless; the unknown-tool branch ignores it).
264
+ def passthrough(name, args)
265
+ {
266
+ name: name,
267
+ content: args.is_a?(Hash) ? args.values.join(" ") : name,
268
+ path: nil, scope: nil,
269
+ start_line: nil, end_line: nil,
270
+ cwd: nil, env: nil, description: nil,
271
+ args: args.is_a?(Hash) ? args : {}
272
+ }
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+ require_relative "../config"
6
+ require_relative "errors"
7
+ require_relative "http"
8
+ require_relative "usage"
9
+
10
+ module Samagotchi
11
+ module LLM
12
+ # A model a host lists. Unknown fields are nil.
13
+ ModelInfo = Data.define(:id, :context_window, :supports_tools, :raw) do
14
+ # true when the list says the model takes images (OpenRouter's
15
+ # architecture.input_modalities, llama.cpp's "multimodal" capability),
16
+ # false when it lists input modalities without "image", else nil.
17
+ def image_input
18
+ return nil unless raw.is_a?(Hash)
19
+
20
+ modalities = raw.dig("architecture", "input_modalities")
21
+ return modalities.include?("image") if modalities.is_a?(Array)
22
+ return true if Array(raw["capabilities"]).include?("multimodal")
23
+
24
+ nil
25
+ end
26
+ end
27
+
28
+ # A tool call the model made. arguments: the parsed Hash, or the raw
29
+ # String when it isn't valid JSON (the tool then reports the error).
30
+ ToolCall = Data.define(:id, :name, :arguments)
31
+
32
+ # One chat completion: text and reasoning ("" when none), tool calls
33
+ # ([] when none), usage (never nil) and the finish reason.
34
+ # +model+ is the model the server says answered (the body's `model`),
35
+ # which can differ from the one asked for; nil when it says nothing.
36
+ ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model) do
37
+ def initialize(model: nil, **fields) = super
38
+ end
39
+
40
+ # The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
41
+ # provider) on our own HTTP layer: one request per #chat, streamed by
42
+ # default, with errors mapped to ProviderError. It speaks the wire format
43
+ # only; the chat loop owns the conversation and the tools.
44
+ class OpenAIChat
45
+ MAX_MODEL_PAGES = 20
46
+ DEFAULT_MODELS_TTL = 60
47
+
48
+ attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout
49
+
50
+ # @param entry [HostRegistry::HostEntry]
51
+ def self.for(entry, **options)
52
+ new(base_url: entry.openai_base_url, host_name: entry.name, api_key_env: entry.api_key_env,
53
+ remote: entry.respond_to?(:remote?) && entry.remote?, **options)
54
+ end
55
+
56
+ # Tool arguments as a Hash; "" is {}, invalid JSON stays a String.
57
+ def self.parse_arguments(raw)
58
+ return raw if raw.is_a?(Hash)
59
+
60
+ text = raw.to_s
61
+ return {} if text.strip.empty?
62
+
63
+ parsed = JSON.parse(text)
64
+ parsed.is_a?(Hash) ? parsed : text
65
+ rescue JSON::ParserError
66
+ text
67
+ end
68
+
69
+ # @param base_url [String] the API base, e.g. http://host:8081/v1
70
+ # @param host_name [String] names the host in errors
71
+ # @param api_key_env [String, nil] the variable holding the API key;
72
+ # nil sends no Authorization header (a local server)
73
+ # @param stream [Boolean] stream the reply (false: one JSON body)
74
+ # @param retries [Boolean] false: one attempt
75
+ # @param timeout [Numeric, nil] read timeout in seconds (default
76
+ # server.read_timeout); the connect timeout is capped by it
77
+ # @param models_ttl [Numeric] how long #context_window reuses the list
78
+ # @param remote [Boolean] a provider on the network, not a local server
79
+ # @param first_token_timeout [Numeric, nil] seconds a streamed answer
80
+ # may take to show something (LLM::HTTP); nil: no limit
81
+ def initialize(base_url:, host_name:, api_key_env: nil, stream: true, retries: true, timeout: nil,
82
+ env: ENV, sleeper: nil, retry_policy: nil, models_ttl: DEFAULT_MODELS_TTL, remote: false,
83
+ first_token_timeout: nil, purpose: "chat")
84
+ @remote = remote
85
+ # What its requests are for, in the log (LLM::HTTP::LOGGED_PURPOSES).
86
+ @purpose = purpose
87
+ @first_token_timeout = first_token_timeout
88
+ @base_url = base_url.to_s.chomp("/")
89
+ @host_name = host_name.to_s
90
+ @api_key_env = api_key_env
91
+ @stream = stream
92
+ @env = env
93
+ @models_ttl = models_ttl
94
+ @models_mutex = Mutex.new
95
+ open_timeout, read_timeout = timeouts(timeout)
96
+ policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
97
+ @http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
98
+ retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout)
99
+ end
100
+
101
+ # @param messages [Array<Hash>] wire messages (role, content, tool_calls,
102
+ # tool_call_id); content is a String or an Array of parts
103
+ # @param tools [Array<Hash>] function definitions ({type:, function:})
104
+ # @param options [Hash] extra request fields (max_tokens, ...)
105
+ # @param on_delta [Proc, nil] called per streamed chunk with content:,
106
+ # reasoning: and payload: (the parsed chunk)
107
+ # @param on_retry [Proc, nil] see LLM::HTTP#stream_lines
108
+ # @param session_id [String, nil] sent as a Session-Id header so a
109
+ # gateway that spreads requests over providers keeps one
110
+ # conversation on one of them (prompt caches, one served model)
111
+ # @return [ChatResponse]
112
+ def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
113
+ session_id: nil)
114
+ request = post_request("#{@base_url}/chat/completions", request_body(messages, tools, model, options),
115
+ session_id: session_id)
116
+ log_fields = { model: model, purpose: @purpose }
117
+ return chat_once(request, cancel_controller, log_fields) unless @stream
118
+
119
+ assembly = Assembly.new
120
+ events = 0
121
+ other = +""
122
+ # A retry streams the answer from the start again.
123
+ restart = lambda do |**event|
124
+ assembly = Assembly.new
125
+ on_retry&.call(**event)
126
+ end
127
+ @http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
128
+ log_fields: log_fields) do |line, shown|
129
+ events += 1 if line.start_with?("data:")
130
+ other << line[0, 200] if !line.start_with?("data:") && other.length < 200
131
+ payload = parse_line(line)
132
+ next unless payload
133
+
134
+ content, reasoning = assembly.add(payload)
135
+ shown.call if !content.empty? || !reasoning.empty? || Assembly.tool_call_delta?(payload)
136
+ on_delta&.call(content: content, reasoning: reasoning, payload: payload)
137
+ end
138
+ # A server that ignores stream: true, or answers with something else
139
+ # entirely, would otherwise end the turn with no answer and no error.
140
+ raise ProtocolError.new("#{@host_name}: the response had no stream events: #{other}", host: @host_name) if events.zero?
141
+
142
+ assembly.response
143
+ end
144
+
145
+ # A provider on the network: no llama.cpp /props to ask for the window.
146
+ def remote? = @remote
147
+
148
+ # @return [Array<ModelInfo>] the host's models (every page)
149
+ def list_models
150
+ models = []
151
+ after = nil
152
+ MAX_MODEL_PAGES.times do
153
+ uri = URI("#{@base_url}/models#{after ? "?after=#{URI.encode_www_form_component(after)}" : ""}")
154
+ body = parse_json(@http.fetch(uri, get_request(uri), log_fields: { purpose: "models" }).body, "model list")
155
+ models.concat(model_entries(body).map { |raw| model_info(raw) })
156
+ break unless body.is_a?(Hash) && body["has_more"] && body["last_id"]
157
+
158
+ after = body["last_id"].to_s
159
+ end
160
+ models
161
+ end
162
+
163
+ # The window (tokens) the host lists for +model+, or nil. The list is
164
+ # read once per models_ttl; a failed listing answers nil.
165
+ def context_window(model:)
166
+ info = cached_models.find { |m| m.id == model.to_s } ||
167
+ cached_models.find { |m| m.id.casecmp?(model.to_s) }
168
+ info&.context_window
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ # Whether the host's list says +model+ takes images (ModelInfo#image_input),
174
+ # or nil when it doesn't say or the listing fails.
175
+ def image_input(model:)
176
+ info = cached_models.find { |m| m.id == model.to_s } ||
177
+ cached_models.find { |m| m.id.casecmp?(model.to_s) }
178
+ info&.image_input
179
+ rescue StandardError
180
+ nil
181
+ end
182
+
183
+ private
184
+
185
+ def timeouts(timeout)
186
+ open_timeout = config_integer("server.open_timeout", 10)
187
+ read_timeout = config_integer("server.read_timeout", 600)
188
+ return [open_timeout, read_timeout] unless timeout
189
+
190
+ [[open_timeout, timeout].min, timeout]
191
+ end
192
+
193
+ def config_integer(key, default)
194
+ value = Samagotchi::Config.get(key).to_i
195
+ value.positive? ? value : default
196
+ rescue StandardError
197
+ default
198
+ end
199
+
200
+ def request_body(messages, tools, model, options)
201
+ body = {
202
+ model: model,
203
+ messages: Array(messages).map { |message| wire_message(message) },
204
+ temperature: 0.0,
205
+ stream: @stream
206
+ }
207
+ body[:stream_options] = { include_usage: true } if @stream
208
+ unless Array(tools).empty?
209
+ body[:tools] = tools
210
+ body[:tool_choice] = "auto"
211
+ end
212
+ body.merge(options || {})
213
+ end
214
+
215
+ # A String stays a String; an Array of parts passes as given.
216
+ def wire_message(message)
217
+ wire = message.to_h.transform_keys(&:to_sym)
218
+ wire[:content] = format_content(wire[:content]) if wire.key?(:content)
219
+ wire
220
+ end
221
+
222
+ def format_content(content)
223
+ return content if content.is_a?(Array) || content.nil?
224
+
225
+ scrub(content.to_s)
226
+ end
227
+
228
+ # Invalid UTF-8 (a tool's garbled output) would make to_json raise.
229
+ def scrub(text)
230
+ text.encoding == Encoding::UTF_8 && !text.valid_encoding? ? text.scrub("?") : text
231
+ end
232
+
233
+ def chat_once(request, cancel_controller, log_fields)
234
+ response = @http.fetch(URI(request.uri.to_s), request, cancel_controller: cancel_controller, log_fields: log_fields)
235
+ body = parse_json(response.body, "chat response")
236
+ message = body.is_a?(Hash) ? body.dig("choices", 0, "message") : nil
237
+ raise ProtocolError.new("#{@host_name}: no message in the chat response", host: @host_name) unless message.is_a?(Hash)
238
+
239
+ calls = Array(message["tool_calls"]).map do |call|
240
+ function = call["function"] || {}
241
+ ToolCall.new(id: call["id"], name: function["name"].to_s, arguments: self.class.parse_arguments(function["arguments"]))
242
+ end
243
+ ChatResponse.new(text: message["content"].to_s,
244
+ reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
245
+ tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
246
+ finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body))
247
+ end
248
+
249
+ def self.served_model(payload)
250
+ model = payload.is_a?(Hash) ? payload["model"] : nil
251
+ model.is_a?(String) && !model.strip.empty? ? model : nil
252
+ end
253
+
254
+ def served_model(payload) = self.class.served_model(payload)
255
+
256
+ # The parsed payload of a `data:` line; nil for blank lines, comments
257
+ # and [DONE]. Error events raise their ProviderError.
258
+ def parse_line(line)
259
+ error = HTTP.sse_error(line, host: @host_name)
260
+ raise error if error
261
+ return nil unless line.start_with?("data:")
262
+
263
+ data = line.delete_prefix("data:").strip
264
+ return nil if data.empty? || data == "[DONE]"
265
+
266
+ payload = parse_json(data, "stream chunk")
267
+ payload.is_a?(Hash) ? payload : nil
268
+ end
269
+
270
+ def parse_json(text, what)
271
+ JSON.parse(text.to_s)
272
+ rescue JSON::ParserError => e
273
+ raise ProtocolError.new("#{@host_name}: malformed #{what}: #{e.message[0, 200]}", host: @host_name)
274
+ end
275
+
276
+ def post_request(url, body, session_id: nil)
277
+ uri = URI(url)
278
+ Net::HTTP::Post.new(uri).tap do |request|
279
+ request["Content-Type"] = "application/json"
280
+ request["Session-Id"] = session_id.to_s unless session_id.to_s.empty?
281
+ authorize(request)
282
+ request.body = JSON.generate(body)
283
+ end
284
+ end
285
+
286
+ def get_request(uri)
287
+ Net::HTTP::Get.new(uri).tap { |request| authorize(request) }
288
+ end
289
+
290
+ def authorize(request)
291
+ return unless @api_key_env
292
+
293
+ key = @env[@api_key_env].to_s
294
+ if key.strip.empty?
295
+ raise AuthError.new("#{@host_name}: set #{@api_key_env} (the API key for host #{@host_name})",
296
+ host: @host_name)
297
+ end
298
+ request["Authorization"] = "Bearer #{key}"
299
+ end
300
+
301
+ def model_entries(body)
302
+ return [] unless body.is_a?(Hash)
303
+
304
+ entries = body["data"].is_a?(Array) ? body["data"] : body["models"]
305
+ Array(entries).select { |entry| entry.is_a?(Hash) }
306
+ end
307
+
308
+ def model_info(raw)
309
+ id = (raw["id"] || raw["model"] || raw["name"]).to_s
310
+ ModelInfo.new(id: id, context_window: window_of(raw), supports_tools: tools_support(raw), raw: raw)
311
+ end
312
+
313
+ # The running window when the list says: context_length, context_window,
314
+ # max_model_len, or llama.cpp's meta.n_ctx (not n_ctx_train).
315
+ def window_of(raw)
316
+ [raw["context_length"], raw["context_window"], raw["max_model_len"], raw.dig("meta", "n_ctx")].each do |value|
317
+ return value if value.is_a?(Integer) && value.positive?
318
+ end
319
+ nil
320
+ end
321
+
322
+ def tools_support(raw)
323
+ listed = raw["supported_parameters"] || raw["capabilities"]
324
+ return nil unless listed.is_a?(Array)
325
+
326
+ listed.include?("tools") ? true : nil
327
+ end
328
+
329
+ def cached_models
330
+ @models_mutex.synchronize do
331
+ fresh = @models_at && (monotonic - @models_at) < @models_ttl
332
+ return @models if fresh
333
+ end
334
+ models = list_models
335
+ @models_mutex.synchronize do
336
+ @models = models
337
+ @models_at = monotonic
338
+ end
339
+ models
340
+ end
341
+
342
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
343
+
344
+ # Streamed deltas put together: text, reasoning, tool calls by index,
345
+ # the finish reason, the usage chunk and the served model.
346
+ class Assembly
347
+ # The chunk carries part of a tool call.
348
+ def self.tool_call_delta?(payload)
349
+ choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
350
+ choice.is_a?(Hash) && choice["delta"].is_a?(Hash) && !Array(choice["delta"]["tool_calls"]).empty?
351
+ end
352
+
353
+ def initialize
354
+ @text = +""
355
+ @reasoning = +""
356
+ @calls = {}
357
+ @finish_reason = nil
358
+ @usage = nil
359
+ @model = nil
360
+ end
361
+
362
+ # @return [Array(String, String)] this chunk's content and reasoning
363
+ def add(payload)
364
+ @usage = Usage.from_payload(payload) || @usage if payload.key?("usage")
365
+ @model = OpenAIChat.served_model(payload) || @model
366
+ choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
367
+ return ["", ""] unless choice.is_a?(Hash)
368
+
369
+ @finish_reason = choice["finish_reason"] if choice["finish_reason"]
370
+ delta = choice["delta"].is_a?(Hash) ? choice["delta"] : {}
371
+ content = delta["content"].to_s
372
+ reasoning = (delta["reasoning_content"] || delta["reasoning"]).to_s
373
+ @text << content
374
+ @reasoning << reasoning
375
+ Array(delta["tool_calls"]).each { |call| add_call(call) }
376
+ [content, reasoning]
377
+ end
378
+
379
+ def response
380
+ calls = @calls.sort_by { |index, _| index }.map do |_, call|
381
+ ToolCall.new(id: call[:id], name: call[:name].to_s, arguments: OpenAIChat.parse_arguments(call[:arguments]))
382
+ end
383
+ ChatResponse.new(text: @text, reasoning: @reasoning, tool_calls: calls, usage: @usage || Usage.none,
384
+ finish_reason: @finish_reason, model: @model)
385
+ end
386
+
387
+ private
388
+
389
+ def add_call(delta)
390
+ return unless delta.is_a?(Hash)
391
+
392
+ index = delta["index"].is_a?(Integer) ? delta["index"] : @calls.size
393
+ call = (@calls[index] ||= { id: nil, name: nil, arguments: +"" })
394
+ call[:id] ||= delta["id"]
395
+ function = delta["function"].is_a?(Hash) ? delta["function"] : {}
396
+ call[:name] ||= function["name"] unless function["name"].to_s.empty?
397
+ call[:arguments] << function["arguments"].to_s
398
+ end
399
+ end
400
+ private_constant :Assembly
401
+ end
402
+ end
403
+ end