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,637 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_profile"
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
+ require_relative "tools/args"
24
+
25
+ module Samagotchi
26
+ # Per-profile strategy for parsing raw model output into internal tool
27
+ # calls ({name:, content:, path:, scope:, ...}) and stripping per-profile
28
+ # thought blocks. KernelLoop delegates here so it no longer carries dual
29
+ # Gemma/Qwen parse code paths; the ModelProfile remains the source of truth
30
+ # for call/thought delimiter tokens.
31
+ class ToolCallParser
32
+ # @param profile [ModelProfile]
33
+ # @return [Gemma, Qwen]
34
+ def self.for_profile(profile)
35
+ profile.name == "qwen36" ? Qwen.new(profile) : Gemma.new(profile)
36
+ end
37
+
38
+ # +text+ without its tool-call blocks (+open+ … +close+); a block the model
39
+ # never closed runs to the end. Surrounding whitespace is trimmed.
40
+ def self.strip_calls(text, open, close)
41
+ result = text.to_s
42
+ while (open_pos = result.index(open))
43
+ close_pos = result.index(close, open_pos + open.length)
44
+ result = result[0...open_pos] + (close_pos ? result[close_pos + close.length..] : "")
45
+ end
46
+ result.strip
47
+ end
48
+
49
+ # ── Gemma 4 ────────────────────────────────────────────────────────────
50
+ # Tool calls: <|tool_call>call:NAME{params}<tool_call|>
51
+ # Thoughts: <|think|>CONTENT (ends at next real <| token) and
52
+ # <|channel>thought......<channel|>
53
+ class Gemma
54
+ # Digits after the first character: plugin tools may have them
55
+ # (Plugin::Api::TOOL_NAME).
56
+ TOOL_CALL_BODY_RE = /\Acall:([a-z_][a-z0-9_]{0,49})\{/
57
+ THOUGHT_CHANNEL_OPEN = "<|channel>thought"
58
+ THOUGHT_CHANNEL_CLOSE = "<channel|>"
59
+ CONTROL_TOKEN_START = "<|"
60
+
61
+ def initialize(profile)
62
+ @tool_call_open = profile.tool_call_open
63
+ @tool_call_close = profile.tool_call_close
64
+ @thought_open = profile.thought_open
65
+ @string_delim = profile.string_delim
66
+ end
67
+
68
+ # Parse canonical Gemma 4 tool calls:
69
+ # <|tool_call>call:NAME{params}<tool_call|>
70
+ # Uses String#index for outer delimiters (no backtracking risk).
71
+ def parse(text)
72
+ results = []
73
+
74
+ pos = 0
75
+ while (open_pos = text.index(@tool_call_open, pos))
76
+ body_start = open_pos + @tool_call_open.length
77
+ close_pos = text.index(@tool_call_close, body_start)
78
+ break unless close_pos
79
+
80
+ body = text[body_start...close_pos]
81
+ if (m = TOOL_CALL_BODY_RE.match(body))
82
+ name = m[1]
83
+ params_raw = body[m.end(0)..]
84
+ params_raw = params_raw[0..-2] if params_raw.end_with?("}")
85
+ results << native_call(name, params_raw.strip)
86
+ end
87
+ pos = close_pos + @tool_call_close.length
88
+ end
89
+
90
+ results
91
+ end
92
+
93
+ # Remove Gemma 4 thought blocks from text.
94
+ # <|think|>CONTENT — ends at the next real <| control token
95
+ # (skipping any <|"|> Gemma string-delimiter tokens) or EOS.
96
+ # <|channel>thought......</channel|> — channel format blocks
97
+ def strip_thought(text)
98
+ result = text
99
+
100
+ # Canonical format: strip from <|think|> to the next real <| (exclusive) or EOS
101
+ while (open_pos = result.index(@thought_open))
102
+ body_start = open_pos + @thought_open.length
103
+ close_pos = next_real_control_token(result, body_start)
104
+ result = if close_pos
105
+ result[0...open_pos] + result[close_pos..]
106
+ else
107
+ result[0...open_pos]
108
+ end
109
+ end
110
+
111
+ # Emitted thought-channel format: strip from <|channel>thought to the
112
+ # corresponding closing tag, or EOS if the close is missing.
113
+ while (open_pos = result.index(THOUGHT_CHANNEL_OPEN))
114
+ close_pos = result.index(THOUGHT_CHANNEL_CLOSE, open_pos)
115
+ result = if close_pos
116
+ result[0...open_pos] + result[close_pos + THOUGHT_CHANNEL_CLOSE.length..]
117
+ else
118
+ result[0...open_pos]
119
+ end
120
+ end
121
+
122
+ result
123
+ end
124
+
125
+ def strip_tool_calls(text)
126
+ ToolCallParser.strip_calls(text, @tool_call_open, @tool_call_close)
127
+ end
128
+
129
+ # Gemma has no unterminated-tool-call recovery flow.
130
+ def parse_with_recovery(response, _partial_fragment = nil)
131
+ [parse(response.to_s), nil]
132
+ end
133
+
134
+ private
135
+
136
+ # Scan forward from +start+ for the next <| sequence that is NOT the Gemma
137
+ # string delimiter <|"|>. Returns the position of that <| or nil if none.
138
+ def next_real_control_token(text, start)
139
+ pos = start
140
+ while (p = text.index(CONTROL_TOKEN_START, pos))
141
+ # Skip over a <|"|> token entirely
142
+ if text[p, @string_delim.length] == @string_delim
143
+ pos = p + @string_delim.length
144
+ else
145
+ return p
146
+ end
147
+ end
148
+ nil
149
+ end
150
+
151
+ # Strip all occurrences of the Gemma string-delimiter token from +str+.
152
+ # Used to clean up bare values that use <|"|> as quoting.
153
+ def strip_gemma_delimiters(str)
154
+ str.gsub(@string_delim, "")
155
+ end
156
+
157
+ # Map native {key: "value"} params to the internal call hash.
158
+ # Uses well-known named params for each tool; falls back to params_raw if
159
+ # no recognised param is present.
160
+ #
161
+ # The model sometimes omits quotes and/or the space after the colon, e.g.
162
+ # {command:ruby -e 'puts 1'} instead of {command: "ruby -e 'puts 1'"}
163
+ # In that case extract_native_params finds nothing and params_raw still
164
+ # contains the "key:" prefix. strip_param_prefix removes it so the actual
165
+ # command/path value is passed to the tool rather than the raw fragment.
166
+ #
167
+ # Gemma 4 may also use its <|"|> string delimiter token instead of plain
168
+ # quotes. strip_gemma_delimiters is applied to all fallback values so that
169
+ # <|"|>value<|"|> is cleaned to just "value" before being dispatched.
170
+ def native_call(name, params_raw)
171
+ params = extract_native_params(params_raw)
172
+
173
+ case name
174
+ when Tools::Execute::NAME
175
+ content = params["command"] ||
176
+ strip_param_prefix(params_raw, "command") ||
177
+ params_raw
178
+ { name: name, content: strip_gemma_delimiters(content), path: nil, scope: nil }
179
+ when Tools::Read::NAME
180
+ content = params["path"] ||
181
+ strip_param_prefix(params_raw, "path") ||
182
+ params_raw
183
+ {
184
+ name: name,
185
+ content: strip_gemma_delimiters(content),
186
+ path: nil,
187
+ scope: nil,
188
+ start_line: params["start_line"],
189
+ end_line: params["end_line"]
190
+ }
191
+ when Tools::Write::NAME
192
+ { name: name, content: params["content"] || "", path: params["path"], scope: nil }
193
+ when Tools::MemoryRead::NAME
194
+ content = params["name"] ||
195
+ strip_param_prefix(params_raw, "name") ||
196
+ params_raw
197
+ { name: name, content: strip_gemma_delimiters(content), path: nil, scope: params["scope"] }
198
+ when Tools::MemoryWrite::NAME
199
+ # The declaration uses "name" (required) and "scope" (required).
200
+ entry_name = params["name"] || ""
201
+ { name: name, content: params["content"] || "", path: entry_name, scope: params["scope"], description: params["description"] ? strip_gemma_delimiters(params["description"]) : nil,
202
+ current_model_only: params["current_model_only"] }
203
+ when Tools::Edit::NAME
204
+ old_text = params["old_text"] || params["old"] || ""
205
+ new_text = params["new_text"] || params["new"] || ""
206
+ content = "<old>#{old_text}</old><new>#{new_text}</new>"
207
+ {
208
+ name: name,
209
+ content: content,
210
+ path: params["path"],
211
+ scope: nil,
212
+ start_line: params["start_line"],
213
+ end_line: params["end_line"]
214
+ }
215
+ when Tools::TaskCreate::NAME
216
+ command = params["command"] ||
217
+ strip_param_prefix(params_raw, "command") ||
218
+ params_raw
219
+ {
220
+ name: name,
221
+ content: strip_gemma_delimiters(command),
222
+ path: nil,
223
+ scope: nil,
224
+ cwd: params["cwd"],
225
+ env: params["env"]
226
+ }
227
+ when Tools::TaskGet::NAME, Tools::TaskStop::NAME
228
+ task_id = params["id"] ||
229
+ params["task_id"] ||
230
+ strip_param_prefix(params_raw, "id") ||
231
+ strip_param_prefix(params_raw, "task_id") ||
232
+ params_raw
233
+ {
234
+ name: name,
235
+ content: strip_gemma_delimiters(task_id),
236
+ path: nil,
237
+ scope: nil
238
+ }
239
+ when Tools::TaskWait::NAME
240
+ task_id = params["id"] ||
241
+ params["task_id"] ||
242
+ strip_param_prefix(params_raw, "id") ||
243
+ strip_param_prefix(params_raw, "task_id") ||
244
+ params_raw
245
+ {
246
+ name: name,
247
+ content: strip_gemma_delimiters(task_id),
248
+ path: nil,
249
+ scope: nil,
250
+ timeout: params["timeout"],
251
+ tail_lines: params["tail_lines"],
252
+ done_pattern: params["done_pattern"]
253
+ }
254
+ when Tools::TaskList::NAME
255
+ { name: name, content: "", path: nil, scope: nil }
256
+ when Tools::WebFetch::NAME
257
+ content = params["url"] ||
258
+ strip_param_prefix(params_raw, "url") ||
259
+ params_raw
260
+ { name: name, content: strip_gemma_delimiters(content), path: nil, scope: nil }
261
+ when Tools::RegisterReminder::NAME
262
+ content = params["name"] || strip_param_prefix(params_raw, "name") || params_raw
263
+ {
264
+ name: name,
265
+ content: strip_gemma_delimiters(content),
266
+ path: nil,
267
+ scope: nil,
268
+ description: params["description"] || "",
269
+ interval_minutes: params["interval_minutes"] || "1"
270
+ }
271
+ when Tools::CancelReminder::NAME
272
+ content = params["name"] || strip_param_prefix(params_raw, "name") || params_raw
273
+ { name: name, content: strip_gemma_delimiters(content), path: nil, scope: nil }
274
+ when Tools::ListReminders::NAME
275
+ { name: name, content: "", path: nil, scope: nil }
276
+ when Tools::ListSessions::NAME
277
+ cwd = params["cwd"] || strip_param_prefix(params_raw, "cwd")
278
+ { name: name, content: "", path: nil, scope: nil, cwd: cwd && strip_gemma_delimiters(cwd) }
279
+ when Tools::SendNote::NAME
280
+ { name: name, content: strip_gemma_delimiters(params["text"].to_s), path: nil, scope: nil,
281
+ session: strip_gemma_delimiters(params["session"].to_s) }
282
+ when Tools::Delegate::NAME
283
+ task = params["task"] || strip_param_prefix(params_raw, "task") || params_raw
284
+ { name: name, content: strip_gemma_delimiters(task.to_s), path: nil, scope: nil,
285
+ model: params["model"] && strip_gemma_delimiters(params["model"]),
286
+ session: params["session"] && strip_gemma_delimiters(params["session"]),
287
+ wait: params["wait"], timeout: params["timeout"] }
288
+ when Tools::DelegateResult::NAME
289
+ { name: name, content: "", path: nil, scope: nil,
290
+ session: params["session"] && strip_gemma_delimiters(params["session"]), timeout: params["timeout"] }
291
+ when Tools::AskUserQuestion::NAME
292
+ { name: name, content: params["question"] ? strip_gemma_delimiters(params["question"]) : strip_gemma_delimiters(params_raw),
293
+ path: nil, scope: nil,
294
+ question: params["question"] ? strip_gemma_delimiters(params["question"]) : strip_gemma_delimiters(params_raw),
295
+ options: parse_ask_options(params, params_raw),
296
+ header: params["header"] ? strip_gemma_delimiters(params["header"]) : nil,
297
+ multi_select: params["multi_select"],
298
+ allow_freeform: params["allow_freeform"] }
299
+ else
300
+ # A registry (plugin) tool, or an unknown one: its arguments as a
301
+ # Hash on args: (typed by the schema at dispatch), and the raw text
302
+ # as content for the unknown-tool error.
303
+ args = Tools::Args.parse_gemma(params_raw, @string_delim) || params
304
+ { name: name, content: strip_gemma_delimiters(params_raw), path: nil, scope: nil, args: args }
305
+ end
306
+ end
307
+
308
+ def parse_ask_options(params, params_raw)
309
+ raw = params["options"]
310
+ # Use tolerant normalizer for String and Array; handles dumb-model noise like ["\"Cats\""] or "]"
311
+ if raw.is_a?(String) || raw.is_a?(Array)
312
+ norm = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw)
313
+ return norm unless norm.empty?
314
+ end
315
+
316
+ # Fallback: extract array-like syntax from raw string e.g. options:["a","b"]
317
+ if params_raw.include?("options")
318
+ # Try to find a JSON array substring first
319
+ if (m = params_raw.match(/options:\s*(\[[^\]]*\])/))
320
+ norm = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(m[1])
321
+ return norm unless norm.empty?
322
+ end
323
+ if (m = params_raw.match(/options:\s*\[([^\]]*)\]/))
324
+ inner = m[1]
325
+ norm = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(inner)
326
+ return norm unless norm.empty?
327
+ end
328
+ # also try Gemma delimiter array
329
+ arr = []
330
+ pos = 0
331
+ while (s = params_raw.index(@string_delim, pos))
332
+ e = params_raw.index(@string_delim, s + @string_delim.length)
333
+ break unless e
334
+
335
+ arr << params_raw[s + @string_delim.length...e]
336
+ pos = e + @string_delim.length
337
+ end
338
+ unless arr.empty?
339
+ norm = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(arr)
340
+ return norm unless norm.empty?
341
+ end
342
+ end
343
+ nil
344
+ end
345
+
346
+ # Strip a known "key:" or "key: " prefix from a raw params string.
347
+ # Returns the remainder of the string, or nil if the prefix is absent.
348
+ # Handles both {command:value} (no space) and {command: value} (space).
349
+ def strip_param_prefix(params_raw, key)
350
+ return nil unless params_raw.start_with?("#{key}:")
351
+
352
+ params_raw.sub(/\A#{Regexp.escape(key)}:\s*/, "")
353
+ end
354
+
355
+ # Extract key: "value" / key: 'value' / key:<|"|>value<|"|> pairs from a
356
+ # native params string.
357
+ # Atomic groups (?>…) prevent ReDoS on adversarial inputs.
358
+ # Both quote styles and the Gemma 4 delimiter are always scanned;
359
+ # double-quoted values take precedence.
360
+ def extract_native_params(params_raw)
361
+ params = {}
362
+ params_raw.scan(/(\w+):\s*"((?>[^"\\]|\\.)*)"/) do |k, v|
363
+ params[k] = unescape_native_value(v)
364
+ end
365
+ params_raw.scan(/(\w+):\s*'((?>[^'\\]|\\.)*)'/) do |k, v|
366
+ params[k] ||= unescape_native_value(v)
367
+ end
368
+ params_raw.scan(/(\w+):\s*(-?\d+)/) do |k, v|
369
+ params[k] ||= v
370
+ end
371
+ # Unquoted boolean values: key:true or key:false
372
+ params_raw.scan(/(\w+):\s*(true|false)\b/) do |k, v|
373
+ params[k] ||= v
374
+ end
375
+ # Gemma 4 string delimiter: key:<|"|>value<|"|>
376
+ # Use plain String#index to avoid any regex backtracking risk on the
377
+ # value content (mirrors the approach used for control-token scanning).
378
+ search_pos = 0
379
+ while (delim_pos = params_raw.index(@string_delim, search_pos))
380
+ val_start = delim_pos + @string_delim.length
381
+ close_pos = params_raw.index(@string_delim, val_start)
382
+ break unless close_pos
383
+
384
+ # Extract the key name by scanning backwards: strip trailing whitespace,
385
+ # expect a colon, then extract trailing word characters — all without
386
+ # a backtracking regex so there is no polynomial-ReDoS risk.
387
+ prefix = params_raw[0...delim_pos].rstrip
388
+ unless prefix.end_with?(":")
389
+ search_pos = close_pos + @string_delim.length
390
+ next
391
+ end
392
+
393
+ key_part = prefix[0...-1].rstrip
394
+ k_end = key_part.length
395
+ k_start = k_end
396
+ # Check each character directly (no regex) — no backtracking risk.
397
+ while k_start > 0
398
+ c = key_part[k_start - 1]
399
+ break unless (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") ||
400
+ (c >= "0" && c <= "9") || c == "_"
401
+
402
+ k_start -= 1
403
+ end
404
+ if k_start < k_end
405
+ k = key_part[k_start...k_end]
406
+ params[k] ||= params_raw[val_start...close_pos]
407
+ end
408
+ search_pos = close_pos + @string_delim.length
409
+ end
410
+ params
411
+ end
412
+
413
+ def unescape_native_value(s)
414
+ s.gsub('\\"', '"').gsub("\\'", "'").gsub("\\n", "\n").gsub("\\\\", "\\")
415
+ end
416
+ end
417
+
418
+ # ── Qwen 3.6 ────────────────────────────────────────────────────────────
419
+ # Per-profile strategy for Qwen 3.6 (function/parameter XML blocks and literal
420
+ # think tokens). Method bodies are ported verbatim from KernelLoop.
421
+ class Qwen
422
+ def initialize(profile)
423
+ @profile = profile
424
+ end
425
+
426
+ def parse(text)
427
+ results = []
428
+
429
+ pos = 0
430
+ tool_open = "<tool_call>"
431
+ tool_close = "</tool_call>"
432
+
433
+ while (open_pos = text.index(tool_open, pos))
434
+ body_start = open_pos + tool_open.length
435
+ close_pos = text.index(tool_close, body_start)
436
+ break unless close_pos
437
+
438
+ body = text[body_start...close_pos]
439
+ if (name = qwen_function_name(body))
440
+ params = qwen_params(body)
441
+ results << qwen_call_to_internal(name, params)
442
+ end
443
+ pos = close_pos + tool_close.length
444
+ end
445
+
446
+ results
447
+ end
448
+
449
+ def strip_thought(text)
450
+ # Remove all complete <think>...</think> blocks with the newlines after them
451
+ result = text.gsub(/<think>.*?<\/think>\n*/m, '')
452
+ # Remove any stray opening tags
453
+ result = result.gsub(/<think>.*?(?=\n|$)/m, '')
454
+ # Remove any orphaned closing tags (and the whitespace around them)
455
+ # The answer's own blank lines stay: they are its markdown paragraphs
456
+ # and lists (a chat host's answer is saved as stripped here).
457
+ result.gsub(/^\s*<\/think>\s*/m, '')
458
+ end
459
+
460
+ def strip_tool_calls(text)
461
+ ToolCallParser.strip_calls(text, "<tool_call>", "</tool_call>")
462
+ end
463
+
464
+ def parse_with_recovery(response, partial_fragment)
465
+ # If the previous turn opened a tool-call block the model never closed,
466
+ # prepend that fragment to this response before parsing. Returns
467
+ # [calls, fragment_or_nil]; a non-nil fragment signals an incomplete call.
468
+ input = partial_fragment ? (partial_fragment + response.to_s) : response.to_s
469
+ [parse(input), qwen_unterminated_tool_call_fragment(input)]
470
+ end
471
+
472
+ private
473
+
474
+ def qwen_function_name(body)
475
+ match = body.match(/<function=([a-z0-9_]+)(?:>|(?=<)|$)/i)
476
+ match && match[1].to_s.downcase
477
+ end
478
+
479
+ def qwen_params(body)
480
+ params = {}
481
+
482
+ body.scan(/<parameter=(\w+)>(.*?)<\/parameter>/m) do |key, value|
483
+ params[key.to_s.downcase] = trim_tag_newline(value)
484
+ end
485
+
486
+ body.scan(/<arg_key>(.*?)<\/arg_key>\s*<arg_value>(.*?)<\/arg_value>/m) do |key, value|
487
+ params[key.to_s.downcase.strip] = trim_tag_newline(value)
488
+ end
489
+
490
+ params
491
+ end
492
+
493
+ def trim_tag_newline(value)
494
+ value.sub(/\A\r?\n/, "").sub(/\r?\n\z/, "")
495
+ end
496
+
497
+ def qwen_unterminated_tool_call_fragment(text)
498
+ open_tag = "<tool_call>"
499
+ close_tag = "</tool_call>"
500
+ pos = 0
501
+
502
+ while (open_pos = text.index(open_tag, pos))
503
+ body_start = open_pos + open_tag.length
504
+ close_pos = text.index(close_tag, body_start)
505
+ return text[open_pos..] unless close_pos
506
+
507
+ pos = close_pos + close_tag.length
508
+ end
509
+
510
+ nil
511
+ end
512
+
513
+ def qwen_call_to_internal(name, params)
514
+ case name
515
+ when Tools::Execute::NAME
516
+ { name: name, content: qwen_param_value(params, "command"), path: nil, scope: nil }
517
+ when Tools::Read::NAME
518
+ {
519
+ name: name,
520
+ content: qwen_param_value(params, "path"),
521
+ path: nil,
522
+ scope: nil,
523
+ start_line: qwen_param_value(params, "start_line"),
524
+ end_line: qwen_param_value(params, "end_line")
525
+ }
526
+ when Tools::Write::NAME
527
+ content = qwen_param_value(params, "content", "text", strip: false)
528
+ { name: name, content: content, path: qwen_param_value(params, "path"), scope: nil }
529
+ when Tools::MemoryRead::NAME
530
+ memory_name = qwen_param_value(params, "name")
531
+ { name: name, content: memory_name, path: nil, scope: qwen_param_value(params, "scope") }
532
+ when Tools::MemoryWrite::NAME
533
+ entry_name = qwen_param_value(params, "name")
534
+ content = qwen_param_value(params, "content", "text", "body", "value", strip: false)
535
+ { name: name, content: content, path: entry_name, scope: qwen_param_value(params, "scope"), description: qwen_param_value(params, "description"),
536
+ current_model_only: qwen_param_value(params, "current_model_only") }
537
+ when Tools::Edit::NAME
538
+ old_text = qwen_param_value(params, "old_text", "old", strip: false)
539
+ new_text = qwen_param_value(params, "new_text", "new", strip: false)
540
+ content = "<old>#{old_text}</old><new>#{new_text}</new>"
541
+ {
542
+ name: name,
543
+ content: content,
544
+ path: qwen_param_value(params, "path"),
545
+ scope: nil,
546
+ start_line: qwen_param_value(params, "start_line"),
547
+ end_line: qwen_param_value(params, "end_line")
548
+ }
549
+ when Tools::TaskCreate::NAME
550
+ {
551
+ name: name,
552
+ content: qwen_param_value(params, "command"),
553
+ path: nil,
554
+ scope: nil,
555
+ cwd: qwen_param_value(params, "cwd"),
556
+ env: qwen_param_value(params, "env", strip: false)
557
+ }
558
+ when Tools::TaskGet::NAME, Tools::TaskStop::NAME
559
+ {
560
+ name: name,
561
+ content: qwen_param_value(params, "id", "task_id"),
562
+ path: nil,
563
+ scope: nil
564
+ }
565
+ when Tools::TaskWait::NAME
566
+ {
567
+ name: name,
568
+ content: qwen_param_value(params, "id", "task_id"),
569
+ path: nil,
570
+ scope: nil,
571
+ timeout: qwen_param_value(params, "timeout"),
572
+ tail_lines: qwen_param_value(params, "tail_lines"),
573
+ done_pattern: qwen_param_value(params, "done_pattern")
574
+ }
575
+ when Tools::TaskList::NAME
576
+ { name: name, content: "", path: nil, scope: nil }
577
+ when Tools::WebFetch::NAME
578
+ { name: name, content: qwen_param_value(params, "url"), path: nil, scope: nil }
579
+ when Tools::RegisterReminder::NAME
580
+ {
581
+ name: name,
582
+ content: qwen_param_value(params, "name"),
583
+ path: nil,
584
+ scope: nil,
585
+ description: qwen_param_value(params, "description"),
586
+ interval_minutes: qwen_param_value(params, "interval_minutes")
587
+ }
588
+ when Tools::CancelReminder::NAME
589
+ { name: name, content: qwen_param_value(params, "name"), path: nil, scope: nil }
590
+ when Tools::ListReminders::NAME
591
+ { name: name, content: "", path: nil, scope: nil }
592
+ when Tools::ListSessions::NAME
593
+ { name: name, content: "", path: nil, scope: nil, cwd: qwen_param_value(params, "cwd") }
594
+ when Tools::SendNote::NAME
595
+ { name: name, content: qwen_param_value(params, "text"), path: nil, scope: nil,
596
+ session: qwen_param_value(params, "session") }
597
+ when Tools::Delegate::NAME
598
+ { name: name, content: qwen_param_value(params, "task"), path: nil, scope: nil,
599
+ model: qwen_param_value(params, "model"), session: qwen_param_value(params, "session"),
600
+ wait: qwen_param_value(params, "wait"), timeout: qwen_param_value(params, "timeout") }
601
+ when Tools::DelegateResult::NAME
602
+ { name: name, content: "", path: nil, scope: nil,
603
+ session: qwen_param_value(params, "session"), timeout: qwen_param_value(params, "timeout") }
604
+ when Tools::AskUserQuestion::NAME
605
+ opts_raw = qwen_param_value(params, "options", strip: false)
606
+ opts = qwen_ask_options(opts_raw)
607
+ {
608
+ name: name,
609
+ content: qwen_param_value(params, "question"),
610
+ path: nil, scope: nil,
611
+ question: qwen_param_value(params, "question"),
612
+ options: opts,
613
+ header: qwen_param_value(params, "header"),
614
+ multi_select: qwen_param_value(params, "multi_select"),
615
+ allow_freeform: qwen_param_value(params, "allow_freeform")
616
+ }
617
+ else
618
+ # A registry (plugin) tool, or an unknown one: the parameters as
619
+ # given (text; typed by the schema at dispatch).
620
+ { name: name, content: params.to_s, path: nil, scope: nil, args: params }
621
+ end
622
+ end
623
+
624
+ def qwen_param_value(params, *keys, strip: true)
625
+ value = keys.lazy.map { |key| params[key] }.find { |candidate| !candidate.nil? }
626
+ return "" if value.nil?
627
+
628
+ strip ? value.to_s.strip : value.to_s
629
+ end
630
+
631
+ def qwen_ask_options(raw)
632
+ norm = Samagotchi::Tools::AskUserQuestion.normalize_options_lenient(raw)
633
+ norm.empty? ? nil : norm
634
+ end
635
+ end
636
+ end
637
+ end