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,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+ require "uri"
5
+
6
+ module Samagotchi
7
+ module Web
8
+ class MarkdownRenderer
9
+ WARNING = "Markdown rendering is enabled, but the optional commonmarker gem is not installed. Install it with: gem install commonmarker"
10
+ # NOTE: `span` is allowed because the syntax highlighter (commonmarker/syntect)
11
+ # emits inline-styled <span>s; keeping it lets highlighting render.
12
+ ALLOWED_ELEMENTS = %w[a blockquote br code del em h1 h2 h3 h4 h5 h6 hr li ol p pre s strong span table tbody td th thead tr ul].freeze
13
+ DROP_ELEMENTS = %w[iframe object script style svg].freeze
14
+ ALLOWED_PROTOCOLS = %w[http https mailto].freeze
15
+
16
+ def initialize(enabled: false)
17
+ @enabled = enabled
18
+ end
19
+
20
+ def enabled?
21
+ @enabled
22
+ end
23
+
24
+ def available?
25
+ enabled? && load_bundled_commonmarker!
26
+ end
27
+
28
+ def warning
29
+ WARNING if enabled? && !load_bundled_commonmarker!
30
+ end
31
+
32
+ def render(markdown)
33
+ return nil unless available?
34
+
35
+ html = Commonmarker.to_html(markdown.to_s, options: {
36
+ render: { escape: true }, # escape raw HTML from source; highlighter output is still emitted
37
+ extension: { header_ids: nil },
38
+ })
39
+ return nil unless html
40
+
41
+ sanitize(html)
42
+ end
43
+
44
+ private
45
+
46
+ def load_bundled_commonmarker!
47
+ return @commonmarker_available unless @commonmarker_available.nil?
48
+
49
+ require "commonmarker"
50
+ @commonmarker_available = true
51
+ rescue LoadError
52
+ @commonmarker_available = false
53
+ end
54
+
55
+ def sanitize(html)
56
+ fragment = Nokogiri::HTML5.fragment(html)
57
+ fragment.traverse do |node|
58
+ next unless node.element?
59
+
60
+ if DROP_ELEMENTS.include?(node.name)
61
+ node.remove
62
+ elsif !ALLOWED_ELEMENTS.include?(node.name)
63
+ node.replace(node.children)
64
+ else
65
+ sanitize_attributes(node)
66
+ end
67
+ end
68
+ fragment.to_html
69
+ end
70
+
71
+ def sanitize_attributes(node)
72
+ node.attribute_nodes.each do |attribute|
73
+ next if allowed_attribute?(node, attribute.name)
74
+ node.remove_attribute(attribute.name)
75
+ end
76
+ return unless node.name == "a"
77
+
78
+ href = node["href"].to_s.strip
79
+ unless safe_href?(href)
80
+ node.remove_attribute("href")
81
+ return
82
+ end
83
+ node["target"] = "_blank"
84
+ node["rel"] = "noopener noreferrer"
85
+ end
86
+
87
+ # The syntax highlighter emits inline `style=` on <span>/<pre>/<code>. Those
88
+ # values come from the (trusted) theme, not user input, and raw HTML from the
89
+ # markdown source is already escaped by commonmarker (escape: true), so style
90
+ # attributes are safe to keep on these elements only.
91
+ def allowed_attribute?(node, name)
92
+ return true if name == "style" && %w[span pre code].include?(node.name)
93
+
94
+ node.name == "a" && %w[href title].include?(name)
95
+ end
96
+
97
+ def safe_href?(href)
98
+ return true if href.start_with?("#", "/")
99
+
100
+ uri = URI.parse(href)
101
+ uri.scheme && ALLOWED_PROTOCOLS.include?(uri.scheme.downcase)
102
+ rescue URI::InvalidURIError
103
+ false
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../model_profile"
4
+ require_relative "../tool_call_parser"
5
+ require_relative "../tool_activity"
6
+ require_relative "../llm/native_tool_normalizer"
7
+ require_relative "../tools/builtins"
8
+ require_relative "../image_store"
9
+
10
+ module Samagotchi
11
+ module Web
12
+ # What a saved assistant message did, for the web turn view's reload: its
13
+ # thinking and its tool calls (name, the params line the live row shows,
14
+ # the output from the tool_response messages after it). Two storage
15
+ # shapes, best effort:
16
+ # * the native loop (a profile, llama.cpp): the raw response as content
17
+ # (thinking + tool-call markup, Qwen or Gemma), then one tool_response
18
+ # whose content joins every call's output with "\n\n---\n\n";
19
+ # * the chat loop (api: openai): the text without thinking, the host's
20
+ # reasoning as +thinking+ (older sessions have none), the calls as
21
+ # tool_calls ({id, name, arguments}), then one tool_response per call
22
+ # (tool_call_id).
23
+ # A message that can't be read gives no parts, never an error.
24
+ #
25
+ # A call's images (a read image, a plugin or MCP tool's) are on its
26
+ # tool_response: chat, per call; native, one list for every call, split
27
+ # by the saved image_counts (older sessions without them: only a lone
28
+ # call gets them).
29
+ #
30
+ # A plugin tool's params are what its live row showed (its preview),
31
+ # saved with the result as tool_params (native: one per call, chat: one
32
+ # per result); its label ("chrome: screenshot") likewise as tool_labels,
33
+ # the part's label:. The web server has no Engine and runs no plugins, so a
34
+ # result without them (older sessions) falls back to the given
35
+ # registry: the built-ins', and a plugin tool's arguments as key="value"
36
+ # from the parsers' args:, as the live row shows them for a tool
37
+ # without a preview.
38
+ module MessageParts
39
+ JOINER = "\n\n---\n\n"
40
+ # A joined output's pieces each start with "[tool]": split there first,
41
+ # so an output that holds the joiner itself stays whole.
42
+ JOINER_BEFORE_TAG = /\n\n---\n\n(?=\[)/
43
+ # The row shows 300 chars and the rest on hover; a read of a big file
44
+ # would otherwise ride along with every reload.
45
+ OUTPUT_MAX = 2000
46
+
47
+ QWEN_THINK = %r{<think>(.*?)</think>}m
48
+ QWEN_OPEN_THINK = %r{\A(.*?)</think>}m # the template opened the block
49
+ GEMMA_THOUGHT = /<\|channel>thought(.*?)(?:<channel\|>|\z)/m
50
+
51
+ CallRef = Struct.new(:name, :arguments)
52
+
53
+ module_function
54
+
55
+ # @param message [Hash] a saved model message (symbol or string keys)
56
+ # @param responses [Array<Hash>] the tool_response messages after it
57
+ # @param registry [Tools::Registry] the session's tools
58
+ # @return [Hash, nil] { thinking:, tools: [{ tool:, params:, output:,
59
+ # output_truncated:, images: }] } with only what it found; nil for nothing
60
+ def for_message(message, responses, registry: Tools::Builtins.default)
61
+ content = field(message, :content).to_s
62
+ calls = field(message, :tool_calls)
63
+ tools = if calls.is_a?(Array) && !calls.empty?
64
+ native_tools(calls, responses, registry)
65
+ else
66
+ markup_tools(content, responses, registry)
67
+ end
68
+ parts = {}
69
+ thinking = [field(message, :thinking).to_s.strip, thinking_of(content)].reject(&:empty?).join("\n\n")
70
+ parts[:thinking] = thinking unless thinking.empty?
71
+ parts[:tools] = tools unless tools.empty?
72
+ parts.empty? ? nil : parts
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ def thinking_of(content)
78
+ blocks = content.scan(QWEN_THINK).flatten
79
+ if blocks.empty? && !content.include?("<think>") && (open = content[QWEN_OPEN_THINK, 1])
80
+ blocks = [open]
81
+ end
82
+ blocks += content.scan(GEMMA_THOUGHT).flatten
83
+ blocks.map(&:strip).reject(&:empty?).join("\n\n")
84
+ end
85
+
86
+ def markup_tools(content, responses, registry)
87
+ calls = []
88
+ calls.concat(ToolCallParser::Qwen.new(ModelProfile.qwen36).parse(content)) if content.include?("<tool_call>")
89
+ calls.concat(ToolCallParser::Gemma.new(ModelProfile.gemma4).parse(content)) if content.include?("<|tool_call>")
90
+ return [] if calls.empty?
91
+
92
+ joined = responses.empty? ? nil : responses.map { |r| field(r, :content).to_s }.join(JOINER)
93
+ outputs = split_outputs(joined, calls.length)
94
+ shown = saved_list(responses, :tool_params, calls.length)
95
+ labels = saved_list(responses, :tool_labels, calls.length)
96
+ images = split_images(responses, calls.length)
97
+ calls.each_with_index.map do |call, i|
98
+ tool_part(call, outputs[i], registry, shown[i], images[i], label: labels[i])
99
+ end
100
+ end
101
+
102
+ # A per-call list saved with the joined result(s), or none when its
103
+ # length doesn't match the calls.
104
+ def saved_list(responses, key, count)
105
+ list = responses.flat_map { |r| Array(field(r, key)) }
106
+ list.length == count ? list : []
107
+ end
108
+
109
+ # Each call's images from the joined tool_response(s).
110
+ def split_images(responses, count)
111
+ images = responses.flat_map { |r| Array(field(r, :images)) }
112
+ return [] if images.empty?
113
+
114
+ counts = responses.flat_map { |r| Array(field(r, :image_counts)) }
115
+ return count == 1 ? [images] : [] unless counts.length == count && counts.sum == images.length
116
+
117
+ counts.map { |n| images.shift(n) }
118
+ end
119
+
120
+ def native_tools(calls, responses, registry)
121
+ by_id = responses.to_h { |r| [field(r, :tool_call_id), r] }
122
+ calls.each_with_index.map do |raw, i|
123
+ ref = CallRef.new(field(raw, :name).to_s, field(raw, :arguments))
124
+ call = LLM::NativeToolNormalizer.normalize(ref) || { name: ref.name }
125
+ response = by_id[field(raw, :id)] || (field(responses[i], :tool_call_id).nil? ? responses[i] : nil)
126
+ tool_part(call, response && field(response, :content).to_s, registry, field(response, :tool_params),
127
+ response && field(response, :images), label: field(response, :tool_labels))
128
+ end
129
+ end
130
+
131
+ # +count+ outputs from a joined tool_response: the "[tool]"-tagged
132
+ # split when it gives the right count, else the plain one; extra pieces
133
+ # stay with the last call, missing ones are nil.
134
+ def split_outputs(joined, count)
135
+ return [] if joined.nil?
136
+ return [joined] if count == 1
137
+
138
+ pieces = joined.split(JOINER_BEFORE_TAG, -1)
139
+ pieces = joined.split(JOINER, -1) if pieces.length != count
140
+ return pieces if pieces.length == count
141
+
142
+ head = pieces.first(count - 1)
143
+ rest = pieces.drop(count - 1)
144
+ head + [rest.empty? ? nil : rest.join(JOINER)]
145
+ end
146
+
147
+ # +shown+ is the saved params line, when it is a String.
148
+ def tool_part(call, output, registry, shown = nil, images = nil, label: nil)
149
+ name = call[:name].to_s
150
+ params = shown.is_a?(String) ? shown : ToolActivity.tool_activity_params(name, call, registry: registry)
151
+ part = { tool: name, params: params.to_s }
152
+ part[:label] = label if label.is_a?(String) && !label.empty?
153
+ unless output.nil?
154
+ part[:output] = output.length > OUTPUT_MAX ? output[0, OUTPUT_MAX] : output
155
+ part[:output_truncated] = true if output.length > OUTPUT_MAX
156
+ end
157
+ refs = Array(images).select { |ref| ref.is_a?(Hash) }
158
+ part[:images] = refs.map { |ref| ImageStore.symbolize(ref).slice(:file, :name, :width, :height) } unless refs.empty?
159
+ part
160
+ end
161
+
162
+ def field(hash, key)
163
+ return nil unless hash.is_a?(Hash)
164
+
165
+ hash.key?(key) ? hash[key] : hash[key.to_s]
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,100 @@
1
+ // Pure model for the per-turn tool/memory "activity" panel.
2
+ //
3
+ // This module has NO DOM dependencies: it maintains a plain-object state
4
+ // (rows keyed by iteration:call_index, each with a status + params + output).
5
+ // app.js owns the rendering and calls into these functions to transition row
6
+ // state from :tool_call_started / :tool_call_completed events. Keeping the
7
+ // state pure makes it unit-testable under `node --test` like data.js/format.js.
8
+
9
+ export const ROW_STATUS = Object.freeze({
10
+ RUNNING: "running",
11
+ OK: "ok",
12
+ ERROR: "error",
13
+ });
14
+
15
+ // What a row shows for its tool: a plugin tool's label ("chrome:
16
+ // screenshot", from its tool_call_started or saved with its result), else
17
+ // the tool's name.
18
+ export function toolName(row) {
19
+ return row?.label || row?.tool || "tool";
20
+ }
21
+
22
+ // Create a fresh activity model for one turn.
23
+ export function newActivity() {
24
+ return { finalized: false, rows: [] };
25
+ }
26
+
27
+ // Stable key for a call within a turn. iteration + call_index are both present
28
+ // on :tool_call_started and :tool_call_completed, so the same key links a
29
+ // "running" row to its completion.
30
+ export function rowKey(iteration, callIndex) {
31
+ return `${iteration ?? 0}:${callIndex ?? 0}`;
32
+ }
33
+
34
+ // Upsert a row from a :tool_call_started event. Returns { row, created }.
35
+ // If a row with the same key already exists (duplicate start), it is reset to
36
+ // running and updated rather than duplicated.
37
+ export function addStarted(model, event = {}) {
38
+ const iteration = event.iteration ?? 0;
39
+ const callIndex = event.call_index ?? 0;
40
+ const key = rowKey(iteration, callIndex);
41
+ let row = model.rows.find((r) => r.key === key);
42
+ if (row) {
43
+ row.status = ROW_STATUS.RUNNING;
44
+ row.tool = event.tool || row.tool;
45
+ row.label = event.label || row.label;
46
+ row.params = event.params || row.params;
47
+ return { row, created: false };
48
+ }
49
+ row = {
50
+ key,
51
+ iteration,
52
+ callIndex,
53
+ tool: event.tool || "tool",
54
+ label: event.label || null,
55
+ params: event.params || "",
56
+ status: ROW_STATUS.RUNNING,
57
+ output: "",
58
+ output_truncated: false,
59
+ };
60
+ model.rows.push(row);
61
+ return { row, created: true };
62
+ }
63
+
64
+ // Update (or synthesize) a row from a :tool_call_completed event. Returns the row.
65
+ // If no matching :tool_call_started was seen (e.g. a replay gap), a row is
66
+ // created on the fly so the completion is never lost.
67
+ export function addCompleted(model, event = {}) {
68
+ const iteration = event.iteration ?? 0;
69
+ const callIndex = event.call_index ?? 0;
70
+ const key = rowKey(iteration, callIndex);
71
+ let row = model.rows.find((r) => r.key === key);
72
+ if (!row) {
73
+ const synthesized = addStarted(model, {
74
+ iteration,
75
+ call_index: callIndex,
76
+ tool: event.tool,
77
+ label: event.label,
78
+ params: event.activity?.params || "",
79
+ });
80
+ row = synthesized.row;
81
+ }
82
+ row.status = event.activity?.status === "error" ? ROW_STATUS.ERROR : ROW_STATUS.OK;
83
+ row.output = event.output || "";
84
+ row.output_truncated = !!event.output_truncated;
85
+ if (event.activity?.params) row.params = event.activity.params;
86
+ // The images the tool returned (a reload in the turn view draws them from
87
+ // the saved parts, turn_view.js reloadRowHtml).
88
+ if (event.images?.length) row.images = event.images;
89
+ return row;
90
+ }
91
+
92
+ // Mark a turn's activity model as finalized (rows stay; rendering collapses).
93
+ export function finalizeActivity(model) {
94
+ if (model) model.finalized = true;
95
+ return model;
96
+ }
97
+
98
+ export function activityCount(model) {
99
+ return model ? model.rows.length : 0;
100
+ }
@@ -0,0 +1,67 @@
1
+ // Annotations: quote a piece of the conversation into the composer.
2
+ //
3
+ // Selecting text in an answer, a thinking block, a tool row or one of your
4
+ // own messages offers "Annotate", which appends a quote block to the
5
+ // composer; the note goes under it. The message stays plain text:
6
+ //
7
+ // From your thinking:
8
+ // > the quoted line
9
+ //
10
+ // the note
11
+ //
12
+ // The label says where the quote came from. Thinking never reaches the
13
+ // model's history, so without it a thinking quote reads as text the model
14
+ // never wrote. No DOM state here beyond Element#closest/querySelector, so it
15
+ // runs under `node --test` with stand-ins.
16
+
17
+ // Where a node inside #history came from: { kind, tool, root } where root
18
+ // is the element a selection is clipped to, or null when that part of the
19
+ // page isn't annotatable (status lines, recaps, cards, a streaming answer).
20
+ export function annotationSource(node) {
21
+ const el = node && node.nodeType === 3 ? node.parentElement : node;
22
+ if (!el?.closest) return null;
23
+ const row = el.closest(".activity-row");
24
+ if (row) {
25
+ const tool = row.querySelector(".activity-tool")?.textContent?.trim() || "tool";
26
+ return { kind: "tool", tool, root: row };
27
+ }
28
+ const thinking = el.closest(".thinking-body");
29
+ if (thinking) return { kind: "thinking", root: thinking };
30
+ // A step's narration in the turn view (the live one streams).
31
+ const step = el.closest(".gen-text");
32
+ if (step) return step.classList.contains("streaming") ? null : { kind: "step", root: step };
33
+ const own = el.closest(".bubble.user .user-message");
34
+ if (own) return { kind: "user", root: own };
35
+ const answer = el.closest(".bubble.output");
36
+ if (answer && !answer.classList.contains("streaming")) return { kind: "answer", root: answer };
37
+ return null;
38
+ }
39
+
40
+ export function sourceLabel(kind, tool) {
41
+ if (kind === "thinking") return "From your thinking:";
42
+ if (kind === "step") return "From your earlier step:";
43
+ if (kind === "tool") return `From the ${tool || "tool"} call:`;
44
+ if (kind === "user") return "From my earlier message:";
45
+ return null;
46
+ }
47
+
48
+ // The label and the `>` lines for +text+, ending in a blank line so the note
49
+ // starts right under it; null when the selection holds no text.
50
+ export function quoteBlock(text, { kind, tool } = {}) {
51
+ const lines = String(text ?? "")
52
+ .replace(/\r\n?/g, "\n")
53
+ .split("\n")
54
+ .map((l) => l.replace(/\s+$/, ""));
55
+ while (lines.length && !lines[0]) lines.shift();
56
+ while (lines.length && !lines[lines.length - 1]) lines.pop();
57
+ if (!lines.length) return null;
58
+ const label = sourceLabel(kind, tool);
59
+ const quoted = lines.map((l) => (l ? `> ${l}` : ">")).join("\n");
60
+ return `${label ? `${label}\n` : ""}${quoted}\n\n`;
61
+ }
62
+
63
+ // The composer's text with +block+ appended after a blank line.
64
+ export function appendQuote(value, block) {
65
+ const before = String(value ?? "").replace(/\s+$/, "");
66
+ return before ? `${before}\n\n${block}` : block;
67
+ }