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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/chi +1140 -0
- data/docs/architecture.md +299 -0
- data/docs/cli.md +490 -0
- data/docs/configuration.md +494 -0
- data/docs/desktop.md +97 -0
- data/docs/guardrails.md +218 -0
- data/docs/hooks.md +309 -0
- data/docs/internals/background-tasks.md +26 -0
- data/docs/internals/context-telemetry.md +36 -0
- data/docs/internals/gemma4-contract.md +23 -0
- data/docs/internals/tool-guardrails.md +45 -0
- data/docs/memory.md +85 -0
- data/docs/plugins.md +819 -0
- data/docs/releasing.md +135 -0
- data/docs/sessions.md +155 -0
- data/lib/samagotchi/bridge/bounded_queue.rb +70 -0
- data/lib/samagotchi/bridge/card_store.rb +126 -0
- data/lib/samagotchi/bridge/event_id.rb +25 -0
- data/lib/samagotchi/bridge/ring_buffer.rb +63 -0
- data/lib/samagotchi/bridge/sse_writer.rb +248 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +189 -0
- data/lib/samagotchi/bridge.rb +993 -0
- data/lib/samagotchi/bridge_client/event_stream.rb +158 -0
- data/lib/samagotchi/bridge_client/sse_parser.rb +51 -0
- data/lib/samagotchi/bridge_client.rb +330 -0
- data/lib/samagotchi/bundle_needs.rb +97 -0
- data/lib/samagotchi/bundles/btw/manifest.yml +10 -0
- data/lib/samagotchi/bundles/btw/plugin.rb +100 -0
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +82 -0
- data/lib/samagotchi/bundles/guardrails/guardrails.md +14 -0
- data/lib/samagotchi/bundles/guardrails/manifest.yml +8 -0
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +210 -0
- data/lib/samagotchi/bundles/known-names/known_names.md +3 -0
- data/lib/samagotchi/bundles/known-names/manifest.yml +14 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +10 -0
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +158 -0
- data/lib/samagotchi/bundles/mcp/manifest.yml +11 -0
- data/lib/samagotchi/bundles/mcp/plugin.rb +631 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +149 -0
- data/lib/samagotchi/bundles/system/delegated.md +10 -0
- data/lib/samagotchi/bundles/system/identity.md +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +11 -0
- data/lib/samagotchi/bundles/system/memory_guide.md +107 -0
- data/lib/samagotchi/bundles/system/self_map.md +55 -0
- data/lib/samagotchi/cancellation_controller.rb +78 -0
- data/lib/samagotchi/client.rb +429 -0
- data/lib/samagotchi/commands/registry.rb +112 -0
- data/lib/samagotchi/config.rb +910 -0
- data/lib/samagotchi/context_note.rb +77 -0
- data/lib/samagotchi/context_quote.rb +21 -0
- data/lib/samagotchi/context_usage.rb +66 -0
- data/lib/samagotchi/context_window.rb +76 -0
- data/lib/samagotchi/debug_log.rb +110 -0
- data/lib/samagotchi/desktop/macos/App.swift +102 -0
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +201 -0
- data/lib/samagotchi/desktop/macos/Hotkey.swift +42 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +42 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +383 -0
- data/lib/samagotchi/desktop/macos.rb +255 -0
- data/lib/samagotchi/desktop.rb +21 -0
- data/lib/samagotchi/desktop_command.rb +143 -0
- data/lib/samagotchi/engine.rb +2807 -0
- data/lib/samagotchi/guardrails/approval.rb +125 -0
- data/lib/samagotchi/guardrails/approvals.rb +177 -0
- data/lib/samagotchi/guardrails/context.rb +71 -0
- data/lib/samagotchi/guardrails/gate.rb +125 -0
- data/lib/samagotchi/guardrails/load_failures.rb +46 -0
- data/lib/samagotchi/guardrails/protected_paths.rb +77 -0
- data/lib/samagotchi/guardrails/rules.rb +199 -0
- data/lib/samagotchi/guardrails/targets.rb +119 -0
- data/lib/samagotchi/guardrails/verdict.rb +134 -0
- data/lib/samagotchi/guardrails.rb +18 -0
- data/lib/samagotchi/hooks/bundle_loader.rb +158 -0
- data/lib/samagotchi/hooks/loader.rb +162 -0
- data/lib/samagotchi/hooks/registry.rb +261 -0
- data/lib/samagotchi/hooks.rb +30 -0
- data/lib/samagotchi/host_registry.rb +315 -0
- data/lib/samagotchi/idle_client.rb +147 -0
- data/lib/samagotchi/idle_recap.rb +549 -0
- data/lib/samagotchi/idle_reminders.rb +101 -0
- data/lib/samagotchi/idle_scheduler.rb +76 -0
- data/lib/samagotchi/image_store.rb +393 -0
- data/lib/samagotchi/installed_gem.rb +38 -0
- data/lib/samagotchi/kernel_loop.rb +1017 -0
- data/lib/samagotchi/launch_mode.rb +34 -0
- data/lib/samagotchi/llm/backend.rb +28 -0
- data/lib/samagotchi/llm/chat_loop.rb +450 -0
- data/lib/samagotchi/llm/errors.rb +329 -0
- data/lib/samagotchi/llm/http.rb +412 -0
- data/lib/samagotchi/llm/model_result.rb +72 -0
- data/lib/samagotchi/llm/native_backend.rb +50 -0
- data/lib/samagotchi/llm/native_tool_normalizer.rb +277 -0
- data/lib/samagotchi/llm/openai_chat.rb +403 -0
- data/lib/samagotchi/llm/usage.rb +79 -0
- data/lib/samagotchi/log.rb +200 -0
- data/lib/samagotchi/log_line.rb +127 -0
- data/lib/samagotchi/log_path.rb +31 -0
- data/lib/samagotchi/log_subscriber.rb +163 -0
- data/lib/samagotchi/memory_bundle/builder.rb +364 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +123 -0
- data/lib/samagotchi/memory_bundle/installer.rb +528 -0
- data/lib/samagotchi/memory_bundle/listing.rb +72 -0
- data/lib/samagotchi/memory_bundle/manifest.rb +225 -0
- data/lib/samagotchi/memory_bundle/merger.rb +52 -0
- data/lib/samagotchi/memory_bundle/placeholder.rb +37 -0
- data/lib/samagotchi/memory_bundle/provenance.rb +257 -0
- data/lib/samagotchi/memory_bundle/source.rb +153 -0
- data/lib/samagotchi/memory_bundle/status.rb +107 -0
- data/lib/samagotchi/memory_bundle/system_bundle.rb +161 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +128 -0
- data/lib/samagotchi/memory_bundle.rb +17 -0
- data/lib/samagotchi/memory_paths.rb +101 -0
- data/lib/samagotchi/model_overlay.rb +53 -0
- data/lib/samagotchi/model_profile.rb +309 -0
- data/lib/samagotchi/muted_memories.rb +66 -0
- data/lib/samagotchi/note_command.rb +163 -0
- data/lib/samagotchi/output_formatter.rb +100 -0
- data/lib/samagotchi/owner_lock.rb +110 -0
- data/lib/samagotchi/pending_input_queue.rb +48 -0
- data/lib/samagotchi/plugin/api.rb +362 -0
- data/lib/samagotchi/plugin/context.rb +193 -0
- data/lib/samagotchi/plugin/loader.rb +126 -0
- data/lib/samagotchi/plugin/service.rb +117 -0
- data/lib/samagotchi/plugin/sessions.rb +150 -0
- data/lib/samagotchi/plugin/side_question.rb +60 -0
- data/lib/samagotchi/plugin/tool_result.rb +24 -0
- data/lib/samagotchi/project_scope.rb +25 -0
- data/lib/samagotchi/prompt.rb +119 -0
- data/lib/samagotchi/prompt_literal_guard.rb +70 -0
- data/lib/samagotchi/recap_store.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +165 -0
- data/lib/samagotchi/self_report.rb +195 -0
- data/lib/samagotchi/send_command.rb +170 -0
- data/lib/samagotchi/served_model.rb +32 -0
- data/lib/samagotchi/session.rb +508 -0
- data/lib/samagotchi/session_commands.rb +527 -0
- data/lib/samagotchi/session_delete_command.rb +105 -0
- data/lib/samagotchi/session_manager.rb +1049 -0
- data/lib/samagotchi/session_metrics.rb +466 -0
- data/lib/samagotchi/session_observer.rb +117 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +118 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1037 -0
- data/lib/samagotchi/terminal_ui/attached_view.rb +264 -0
- data/lib/samagotchi/terminal_ui/event_renderer.rb +192 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +291 -0
- data/lib/samagotchi/terminal_ui/image_input.rb +36 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +324 -0
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +111 -0
- data/lib/samagotchi/terminal_ui/line_reader.rb +113 -0
- data/lib/samagotchi/terminal_ui/live_region.rb +36 -0
- data/lib/samagotchi/terminal_ui/plain_surface.rb +51 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +153 -0
- data/lib/samagotchi/terminal_ui/question_slot.rb +131 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +216 -0
- data/lib/samagotchi/terminal_ui/repl_input.rb +138 -0
- data/lib/samagotchi/terminal_ui/screen.rb +316 -0
- data/lib/samagotchi/terminal_ui/surface.rb +47 -0
- data/lib/samagotchi/terminal_ui/thinking_line.rb +101 -0
- data/lib/samagotchi/terminal_ui.rb +1992 -0
- data/lib/samagotchi/thinking_ticker.rb +110 -0
- data/lib/samagotchi/thought_stream_splitter.rb +149 -0
- data/lib/samagotchi/token_usage.rb +88 -0
- data/lib/samagotchi/tool_activity.rb +216 -0
- data/lib/samagotchi/tool_call_parser.rb +637 -0
- data/lib/samagotchi/tool_declarations.rb +561 -0
- data/lib/samagotchi/tool_runner.rb +211 -0
- data/lib/samagotchi/tools/args.rb +259 -0
- data/lib/samagotchi/tools/ask_user_question.rb +152 -0
- data/lib/samagotchi/tools/builtins.rb +122 -0
- data/lib/samagotchi/tools/cancel_reminder.rb +21 -0
- data/lib/samagotchi/tools/delegate.rb +167 -0
- data/lib/samagotchi/tools/delegate_result.rb +53 -0
- data/lib/samagotchi/tools/delegate_wait.rb +153 -0
- data/lib/samagotchi/tools/edit.rb +155 -0
- data/lib/samagotchi/tools/execute.rb +214 -0
- data/lib/samagotchi/tools/list_reminders.rb +20 -0
- data/lib/samagotchi/tools/list_sessions.rb +74 -0
- data/lib/samagotchi/tools/memory.rb +256 -0
- data/lib/samagotchi/tools/output_guardrails.rb +93 -0
- data/lib/samagotchi/tools/peers.rb +18 -0
- data/lib/samagotchi/tools/read.rb +182 -0
- data/lib/samagotchi/tools/register_reminder.rb +53 -0
- data/lib/samagotchi/tools/registry.rb +60 -0
- data/lib/samagotchi/tools/send_note.rb +49 -0
- data/lib/samagotchi/tools/task_create.rb +29 -0
- data/lib/samagotchi/tools/task_get.rb +39 -0
- data/lib/samagotchi/tools/task_list.rb +43 -0
- data/lib/samagotchi/tools/task_runtime.rb +311 -0
- data/lib/samagotchi/tools/task_stop.rb +29 -0
- data/lib/samagotchi/tools/task_wait.rb +104 -0
- data/lib/samagotchi/tools/tool_path.rb +18 -0
- data/lib/samagotchi/tools/web_fetch.rb +163 -0
- data/lib/samagotchi/tools/write.rb +26 -0
- data/lib/samagotchi/turn_flow.rb +242 -0
- data/lib/samagotchi/turn_note.rb +76 -0
- data/lib/samagotchi/turn_tally.rb +101 -0
- data/lib/samagotchi/version.rb +7 -0
- data/lib/samagotchi/vision_context.rb +132 -0
- data/lib/samagotchi/vision_support.rb +109 -0
- data/lib/samagotchi/web/app.rb +1349 -0
- data/lib/samagotchi/web/markdown_renderer.rb +107 -0
- data/lib/samagotchi/web/message_parts.rb +169 -0
- data/lib/samagotchi/web/public/activity.js +100 -0
- data/lib/samagotchi/web/public/annotations.js +67 -0
- data/lib/samagotchi/web/public/app.js +2382 -0
- data/lib/samagotchi/web/public/card.js +74 -0
- data/lib/samagotchi/web/public/chat_view.js +360 -0
- data/lib/samagotchi/web/public/chunk_router.js +25 -0
- data/lib/samagotchi/web/public/command_complete.js +39 -0
- data/lib/samagotchi/web/public/composer_size.js +19 -0
- data/lib/samagotchi/web/public/copy.js +142 -0
- data/lib/samagotchi/web/public/ctx.js +35 -0
- data/lib/samagotchi/web/public/data.js +256 -0
- data/lib/samagotchi/web/public/format.js +232 -0
- data/lib/samagotchi/web/public/hold.js +78 -0
- data/lib/samagotchi/web/public/images.js +77 -0
- data/lib/samagotchi/web/public/index.html +568 -0
- data/lib/samagotchi/web/public/init_row.js +60 -0
- data/lib/samagotchi/web/public/model_pick.js +23 -0
- data/lib/samagotchi/web/public/question_card.js +100 -0
- data/lib/samagotchi/web/public/route.js +17 -0
- data/lib/samagotchi/web/public/scope.js +36 -0
- data/lib/samagotchi/web/public/scroll.js +24 -0
- data/lib/samagotchi/web/public/sentences.js +88 -0
- data/lib/samagotchi/web/public/sessions_list.js +60 -0
- data/lib/samagotchi/web/public/strip.js +25 -0
- data/lib/samagotchi/web/public/tally.js +37 -0
- data/lib/samagotchi/web/public/thinking_ticker.js +79 -0
- data/lib/samagotchi/web/public/timing.js +185 -0
- data/lib/samagotchi/web/public/turn_events.js +209 -0
- data/lib/samagotchi/web/public/turn_model.js +204 -0
- data/lib/samagotchi/web/public/turn_view.js +587 -0
- data/lib/samagotchi/web/server.rb +183 -0
- data/lib/samagotchi/web/session_hub.rb +329 -0
- data/lib/samagotchi/web/session_summary.rb +85 -0
- data/lib/samagotchi/worker.rb +635 -0
- data/lib/samagotchi/worker_idle_exit.rb +87 -0
- data/lib/samagotchi.rb +12 -0
- 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
|
+
}
|