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,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../log"
|
|
5
|
+
require_relative "../guardrails/context"
|
|
6
|
+
require_relative "../idle_client"
|
|
7
|
+
require_relative "side_question"
|
|
8
|
+
require_relative "sessions"
|
|
9
|
+
|
|
10
|
+
module Samagotchi
|
|
11
|
+
module Plugin
|
|
12
|
+
# The Engine's side of a Context: callables, so the Engine itself is
|
|
13
|
+
# never handed out. +messages+ returns the conversation, +notify+ takes
|
|
14
|
+
# (text, level, label), +ask_user+ takes (question:, options:, header:,
|
|
15
|
+
# allow_freeform:, hook:) like the hook runtime's, +card+ takes
|
|
16
|
+
# Engine#show_card's keywords and returns the id, +ask_model+ takes
|
|
17
|
+
# (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
|
|
18
|
+
# the answer text.
|
|
19
|
+
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
20
|
+
# +model_name+ and +state_dir+ are what ctx.sessions forks with.
|
|
21
|
+
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
22
|
+
:ask_model, :model_name, :state_dir, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
25
|
+
# sent nothing usable. The message says why, for the user.
|
|
26
|
+
class ModelError < StandardError; end
|
|
27
|
+
|
|
28
|
+
# ctx.ask_model was cancelled (its cancel: controller).
|
|
29
|
+
class ModelCancelled < ModelError; end
|
|
30
|
+
|
|
31
|
+
# What a plugin's handlers get as +ctx+ (docs/plugins.md): the session
|
|
32
|
+
# they run for, the bundle's settings and storage, a log, and the
|
|
33
|
+
# user-facing helpers hooks have. One per plugin, for the Engine's life:
|
|
34
|
+
# each read is of the session now.
|
|
35
|
+
class Context
|
|
36
|
+
# Debug-log records tagged plugins, with bundle=<bundle> (tags are a
|
|
37
|
+
# closed list, LogLine::TAGS).
|
|
38
|
+
class Logger
|
|
39
|
+
def initialize(bundle)
|
|
40
|
+
@bundle = bundle
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
%i[debug info warn error].each do |level|
|
|
44
|
+
define_method(level) do |event, **fields|
|
|
45
|
+
Samagotchi::Log.public_send(level, :plugins, event.to_s, bundle: @bundle, **fields)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [String] the bundle the plugin came with
|
|
51
|
+
attr_reader :bundle
|
|
52
|
+
|
|
53
|
+
# @return [Hash] config.yml `bundles: <bundle>:` (string keys), frozen
|
|
54
|
+
attr_reader :settings
|
|
55
|
+
|
|
56
|
+
# @return [Logger]
|
|
57
|
+
attr_reader :log
|
|
58
|
+
|
|
59
|
+
# @param label [String] "<file> (bundle <name>)", what notices and
|
|
60
|
+
# questions are labelled by
|
|
61
|
+
# @param host [Host]
|
|
62
|
+
def initialize(bundle:, label:, settings:, host:, env: ENV)
|
|
63
|
+
@bundle = bundle.to_s
|
|
64
|
+
@label = label
|
|
65
|
+
@settings = deep_freeze(settings.is_a?(Hash) ? settings : {})
|
|
66
|
+
@host = host
|
|
67
|
+
@env = env
|
|
68
|
+
@log = Logger.new(@bundle)
|
|
69
|
+
@git = Guardrails::GitInfo.new
|
|
70
|
+
@data_dir = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @return [String, nil] the session's id (nil before the first one)
|
|
74
|
+
def session_id = @host.session_id.call
|
|
75
|
+
|
|
76
|
+
# @return [String] the session's working directory
|
|
77
|
+
def cwd = @host.cwd.call || Dir.pwd
|
|
78
|
+
|
|
79
|
+
# @return [String, nil] the git checkout holding #cwd
|
|
80
|
+
def repo_root = @git.root(cwd)
|
|
81
|
+
|
|
82
|
+
# $XDG_STATE_HOME/samagotchi/plugins/<bundle>/, created on first use.
|
|
83
|
+
# @return [String]
|
|
84
|
+
def data_dir
|
|
85
|
+
@data_dir ||= begin
|
|
86
|
+
xdg = @env.fetch("XDG_STATE_HOME", "").to_s.strip
|
|
87
|
+
base = xdg.empty? ? File.join(Dir.home, ".local", "state") : xdg
|
|
88
|
+
File.join(base, "samagotchi", "plugins", @bundle).tap { |dir| FileUtils.mkdir_p(dir) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The conversation so far, a frozen copy, without the system prompt.
|
|
93
|
+
# While a turn runs, a session worker's (attached TUI, web) adds the
|
|
94
|
+
# turn so far: its prompt, the model's text and the lines merged into
|
|
95
|
+
# it; the REPL's is the conversation before that turn
|
|
96
|
+
# (#messages_partial?).
|
|
97
|
+
# @return [Array<Hash>]
|
|
98
|
+
def messages
|
|
99
|
+
Array(@host.messages.call).map { |message| message.dup.freeze }.freeze
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Whether #messages leaves out a running turn (the REPL mid-turn), so
|
|
103
|
+
# a plugin can say what its answer is about.
|
|
104
|
+
def messages_partial? = !!@host.messages_partial&.call
|
|
105
|
+
|
|
106
|
+
# One line to the user, labelled by the plugin, like a hook's
|
|
107
|
+
# event[:notify]. Every UI shows it, during a turn or between turns.
|
|
108
|
+
# @param level [Symbol] :info or :warn
|
|
109
|
+
def notify(text, level: :info)
|
|
110
|
+
@host.notify.call(text.to_s, level, @label)
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A card in every UI: a title, a body (markdown in the web, plain
|
|
115
|
+
# text in the terminal) and actions, each a command line the session
|
|
116
|
+
# runs when the user picks it (docs/plugins.md, Cards). Showing a card
|
|
117
|
+
# with an earlier card's id replaces that card.
|
|
118
|
+
# @param actions [Array<Hash>] {label:, command:} ("/hello again")
|
|
119
|
+
# @param level [Symbol] :info or :warn
|
|
120
|
+
# @param id [String, nil] an earlier card's id to replace it
|
|
121
|
+
# @return [String] the card's id
|
|
122
|
+
# @raise [ArgumentError] no title, a bad level or action
|
|
123
|
+
def card(title:, body: "", actions: [], level: :info, id: nil)
|
|
124
|
+
@host.card.call(source: @bundle, title: title, body: body, actions: actions, level: level, id: id)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A side answer (plan D7): one request to the session's current model
|
|
128
|
+
# on its host, with no tools and thinking off. It writes nothing,
|
|
129
|
+
# fires no hooks, and doesn't touch the conversation. It blocks until
|
|
130
|
+
# the answer comes, so call it from an anytime command or your own
|
|
131
|
+
# thread; with a local server that runs one request at a time it
|
|
132
|
+
# waits for a running turn.
|
|
133
|
+
# @param messages [Array<Hash>] the conversation to ask about
|
|
134
|
+
# (usually #messages); sent as a transcript without the system
|
|
135
|
+
# prompt, tool calls, tool output and thinking; an image is a line
|
|
136
|
+
# naming it
|
|
137
|
+
# @param prompt [String] the question
|
|
138
|
+
# @param system [String, nil] instructions (a short default)
|
|
139
|
+
# @param timeout [Numeric] seconds
|
|
140
|
+
# @param max_tokens [Integer, nil] the answer's limit (default
|
|
141
|
+
# ASK_MAX_TOKENS); an answer cut off ends with "…"
|
|
142
|
+
# @param cancel [CancellationController, nil] cancelling it aborts
|
|
143
|
+
# the request
|
|
144
|
+
# @return [String] the answer ("" when the model said nothing)
|
|
145
|
+
# @raise [ModelError] the request failed or timed out
|
|
146
|
+
# @raise [ModelCancelled] +cancel+ was cancelled
|
|
147
|
+
def ask_model(messages:, prompt:, system: nil, timeout: 120, max_tokens: nil, cancel: nil)
|
|
148
|
+
raise ArgumentError, "ask_model needs a prompt" if prompt.to_s.strip.empty?
|
|
149
|
+
|
|
150
|
+
request = SideQuestion.request(messages: messages, prompt: prompt, system: system)
|
|
151
|
+
limit = max_tokens.nil? ? ASK_MAX_TOKENS : Integer(max_tokens)
|
|
152
|
+
@host.ask_model.call(request, timeout: Float(timeout), max_tokens: limit, cancel_controller: cancel).to_s
|
|
153
|
+
rescue IdleClient::SummarizeError => e
|
|
154
|
+
raise ModelError, e.message
|
|
155
|
+
rescue LLM::RequestCancelled
|
|
156
|
+
raise ModelCancelled, "the request was cancelled"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ctx.ask_model's answer limit when none is given.
|
|
160
|
+
ASK_MAX_TOKENS = 1024
|
|
161
|
+
|
|
162
|
+
# Other sessions: fork one from a conversation, send one a message,
|
|
163
|
+
# read one (Sessions).
|
|
164
|
+
# @return [Sessions]
|
|
165
|
+
def sessions
|
|
166
|
+
@sessions ||= Sessions.new(@host)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# A single-select question through the question flow, like a hook's
|
|
170
|
+
# event[:ask_user].
|
|
171
|
+
# @return [Hash, nil] {selected:, freeform:, selected_indices:}, or nil
|
|
172
|
+
# (no one to ask, cancelled, bad options)
|
|
173
|
+
def ask_user(question:, options:, header: nil, allow_freeform: false)
|
|
174
|
+
@host.ask_user.call(question: question, options: options, header: header, allow_freeform: allow_freeform,
|
|
175
|
+
hook: @label)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Whether the running turn was cancelled (a long tool should stop).
|
|
179
|
+
def cancelled? = !!@host.cancelled.call
|
|
180
|
+
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def deep_freeze(value)
|
|
184
|
+
case value
|
|
185
|
+
when Hash then value.to_h { |k, v| [deep_freeze(k), deep_freeze(v)] }.freeze
|
|
186
|
+
when Array then value.map { |v| deep_freeze(v) }.freeze
|
|
187
|
+
when String then value.dup.freeze
|
|
188
|
+
else value
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "api"
|
|
4
|
+
require_relative "context"
|
|
5
|
+
require_relative "../hooks"
|
|
6
|
+
require_relative "../log"
|
|
7
|
+
require_relative "../version"
|
|
8
|
+
require_relative "../memory_bundle/manifest"
|
|
9
|
+
require_relative "../memory_bundle/provenance"
|
|
10
|
+
|
|
11
|
+
module Samagotchi
|
|
12
|
+
module Plugin
|
|
13
|
+
# What a plugin registers into: an Engine's own registries.
|
|
14
|
+
# +context_for+ is (bundle_name, settings, label) → the Plugin::Context
|
|
15
|
+
# its handlers get; +tools_changed+ drops what was built from the tools
|
|
16
|
+
# (the Engine's system prompts); +services+ (Plugin::Services) is what
|
|
17
|
+
# Engine#shutdown stops; +stage_tools+ is (bundle, specs, context),
|
|
18
|
+
# chi.replace_tools' set, which the Engine applies on its turn thread;
|
|
19
|
+
# +init+ adds a chi.init task (Engine#add_init_task).
|
|
20
|
+
Registries = Struct.new(:commands, :tools, :hooks, :context_for, :tools_changed, :services, :stage_tools, :init,
|
|
21
|
+
keyword_init: true)
|
|
22
|
+
|
|
23
|
+
# Loads installed bundles' plugins (manifest plugin: {file:, sha256:})
|
|
24
|
+
# into an Engine's registries (docs/plugins.md).
|
|
25
|
+
#
|
|
26
|
+
# For each bundle, by name: the file must match the sha256 recorded at
|
|
27
|
+
# install and chi must meet the bundle's requires_chi. The file is
|
|
28
|
+
# module_eval'd into the bundle's namespace (as its hooks are), the
|
|
29
|
+
# class named like the file (plugin.rb → Plugin) is built with the
|
|
30
|
+
# bundle's settings, and its #register gets a Plugin::Api. What it
|
|
31
|
+
# registered takes effect only when #register returns: a plugin that
|
|
32
|
+
# fails adds nothing.
|
|
33
|
+
#
|
|
34
|
+
# A failure is logged and added to +failures+ (not required: tool calls
|
|
35
|
+
# still run), which the Engine announces once. Not fail-closed.
|
|
36
|
+
module Loader
|
|
37
|
+
TAG = :plugins
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# @param registries [Registries]
|
|
42
|
+
# @param failures [Guardrails::LoadFailures, nil]
|
|
43
|
+
# @param settings [Hash{String => Hash}] config.yml `bundles:`
|
|
44
|
+
# @return [Array<String>] the bundles whose plugin loaded
|
|
45
|
+
def load_installed(registries, failures: nil, settings: {})
|
|
46
|
+
loaded = []
|
|
47
|
+
MemoryBundle::Provenance.each_installed_with_plugin do |bundle_name, data|
|
|
48
|
+
ok = load_bundle(bundle_name, data, registries, failures: failures, settings: settings[bundle_name.to_s] || {})
|
|
49
|
+
loaded << bundle_name if ok
|
|
50
|
+
end
|
|
51
|
+
loaded
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Log.error(TAG, "plugins_load_failed", echo: "[samagotchi:plugins] failed to load bundle plugins: #{e.class}: #{e.message}",
|
|
54
|
+
error: e.class.name)
|
|
55
|
+
loaded || []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Boolean] whether the plugin loaded
|
|
59
|
+
def load_bundle(bundle_name, data, registries, failures: nil, settings: {})
|
|
60
|
+
file = MemoryBundle::Provenance.new(name: bundle_name).plugin_path(data) unless data[:error]
|
|
61
|
+
basename = file ? File.basename(file) : "plugin"
|
|
62
|
+
reason = data[:error] || unloadable_reason(file, data)
|
|
63
|
+
return failed(bundle_name, basename, reason, failures) if reason
|
|
64
|
+
|
|
65
|
+
api = nil
|
|
66
|
+
plugin = instantiate(bundle_name, file, settings)
|
|
67
|
+
plugin_label = label(bundle_name, basename)
|
|
68
|
+
api = Api.new(bundle: bundle_name, label: plugin_label, registries: registries,
|
|
69
|
+
context: registries.context_for&.call(bundle_name, settings, plugin_label))
|
|
70
|
+
plugin.register(api)
|
|
71
|
+
api.commit!
|
|
72
|
+
Log.info(TAG, "plugin_loaded", bundle: bundle_name, file: basename, **api.counts)
|
|
73
|
+
true
|
|
74
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- a plugin's SyntaxError or exit must not stop chi
|
|
75
|
+
api&.abort!
|
|
76
|
+
raise if e.is_a?(Interrupt)
|
|
77
|
+
|
|
78
|
+
failed(bundle_name, basename, "#{e.class}: #{e.message}", failures)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# What a plugin's hooks, notices and failures are named by.
|
|
82
|
+
def label(bundle_name, basename) = "#{basename} (bundle #{bundle_name})"
|
|
83
|
+
|
|
84
|
+
# Why the installed file can't be loaded, or nil.
|
|
85
|
+
def unloadable_reason(file, data)
|
|
86
|
+
failure = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION)
|
|
87
|
+
return failure if failure
|
|
88
|
+
return "the file is missing" unless file && File.file?(file)
|
|
89
|
+
|
|
90
|
+
Hooks::BundleLoader.sha_mismatch(file, data[:plugin][:sha256], required: true)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# module_eval the file into a fresh module in the bundle's namespace
|
|
94
|
+
# (Samagotchi::Bundles::<bundle>::PluginLoad<n>: each load gets new
|
|
95
|
+
# classes, not the last load's reopened) and build its class
|
|
96
|
+
# (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are built:
|
|
97
|
+
# an initialize that takes an argument gets the settings.
|
|
98
|
+
def instantiate(bundle_name, file, settings)
|
|
99
|
+
namespace = fresh_namespace(bundle_name)
|
|
100
|
+
namespace.module_eval(File.read(file), file, 1)
|
|
101
|
+
class_name = File.basename(file, ".rb").split("_").map(&:capitalize).join
|
|
102
|
+
klass = namespace.const_get(class_name, false)
|
|
103
|
+
plugin = Hooks.build_plugin(klass, settings)
|
|
104
|
+
raise ArgumentError, "#{class_name} does not respond to #register" unless plugin.respond_to?(:register)
|
|
105
|
+
|
|
106
|
+
plugin
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
LOADS = Mutex.new
|
|
110
|
+
|
|
111
|
+
def fresh_namespace(bundle_name)
|
|
112
|
+
LOADS.synchronize do
|
|
113
|
+
@loads = (@loads || 0) + 1
|
|
114
|
+
Hooks::BundleLoader.namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def failed(bundle_name, basename, reason, failures)
|
|
119
|
+
Log.warn(TAG, "plugin_not_loaded", echo: "[samagotchi:plugins] bundle '#{bundle_name}' plugin '#{basename}' not loaded: #{reason}",
|
|
120
|
+
bundle: bundle_name, file: basename)
|
|
121
|
+
failures&.add("plugin #{basename} (bundle #{bundle_name})", reason, required: false)
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require_relative "../log"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Plugin
|
|
8
|
+
# A plugin's long-lived thing (a server process, a connection), from
|
|
9
|
+
# chi.service (docs/plugins.md, Services). Its block starts it: on
|
|
10
|
+
# first #value, or at register time with eager: true. What the block
|
|
11
|
+
# returns is #value. Stop callbacks (#on_stop, given inside the block)
|
|
12
|
+
# run when the Engine shuts down (Engine#shutdown), newest first.
|
|
13
|
+
class Service
|
|
14
|
+
# The service was stopped (the Engine shut down): it doesn't start
|
|
15
|
+
# again.
|
|
16
|
+
class Stopped < StandardError; end
|
|
17
|
+
|
|
18
|
+
# @return [String] "<bundle>:<name>"
|
|
19
|
+
attr_reader :name
|
|
20
|
+
|
|
21
|
+
def initialize(name, &start)
|
|
22
|
+
@name = name
|
|
23
|
+
@start = start
|
|
24
|
+
@mutex = Monitor.new
|
|
25
|
+
@state = :idle
|
|
26
|
+
@value = nil
|
|
27
|
+
@on_stop = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Start it if it hasn't started, and return what its block returned.
|
|
31
|
+
# A block that raises leaves it idle (its on_stop callbacks so far
|
|
32
|
+
# run), so the next use tries again.
|
|
33
|
+
# @raise [Stopped] after #stop
|
|
34
|
+
def value
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
raise Stopped, "service #{@name} is stopped" if @state == :stopped
|
|
37
|
+
return @value if @state == :running
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
@value = @start.call(self)
|
|
41
|
+
@state = :running
|
|
42
|
+
Log.info(:plugins, "service_started", service: @name)
|
|
43
|
+
@value
|
|
44
|
+
rescue Exception # rubocop:disable Lint/RescueException -- clean up, then re-raise as is
|
|
45
|
+
run_on_stop
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
alias start value
|
|
51
|
+
|
|
52
|
+
# A callback for #stop, given in the start block (close the pipes,
|
|
53
|
+
# kill the process).
|
|
54
|
+
def on_stop(&block)
|
|
55
|
+
raise ArgumentError, "on_stop needs a block" unless block
|
|
56
|
+
|
|
57
|
+
@mutex.synchronize { @on_stop << block }
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
def running? = @state == :running
|
|
63
|
+
|
|
64
|
+
# @return [Symbol] :idle, :running or :stopped
|
|
65
|
+
def state = @state
|
|
66
|
+
|
|
67
|
+
# Run the stop callbacks (newest first) once; a raise is logged, the
|
|
68
|
+
# rest still run. It never starts again after this.
|
|
69
|
+
def stop
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
return if @state == :stopped
|
|
72
|
+
|
|
73
|
+
was = @state
|
|
74
|
+
@state = :stopped
|
|
75
|
+
@value = nil
|
|
76
|
+
run_on_stop
|
|
77
|
+
Log.info(:plugins, "service_stopped", service: @name) if was == :running
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def run_on_stop
|
|
85
|
+
callbacks = @on_stop.reverse
|
|
86
|
+
@on_stop = []
|
|
87
|
+
callbacks.each do |callback|
|
|
88
|
+
callback.call
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
Log.warn(:plugins, "service_stop_failed", service: @name, error: e.class.name, msg: e.message)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# An Engine's services, in the order they were registered. #stop_all
|
|
96
|
+
# stops them newest first.
|
|
97
|
+
class Services
|
|
98
|
+
def initialize
|
|
99
|
+
@list = []
|
|
100
|
+
@mutex = Mutex.new
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def add(service)
|
|
104
|
+
@mutex.synchronize { @list << service }
|
|
105
|
+
service
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# @return [Array<Service>]
|
|
109
|
+
def to_a = @mutex.synchronize { @list.dup }
|
|
110
|
+
|
|
111
|
+
def stop_all
|
|
112
|
+
to_a.reverse_each(&:stop)
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../bridge_client"
|
|
5
|
+
require_relative "../bridge/turn_accumulator"
|
|
6
|
+
require_relative "../tools/delegate"
|
|
7
|
+
|
|
8
|
+
module Samagotchi
|
|
9
|
+
# Loaded on first use: session_manager requires terminal_ui, which
|
|
10
|
+
# requires the Engine and so the plugins (a require cycle otherwise).
|
|
11
|
+
autoload :SessionManager, File.expand_path("../session_manager", __dir__)
|
|
12
|
+
|
|
13
|
+
module Plugin
|
|
14
|
+
# ctx.sessions (docs/plugins.md, Sessions): other sessions, from a
|
|
15
|
+
# plugin. A fork is an ordinary chi session in its own worker, shown in
|
|
16
|
+
# every list as a child of this one (↳ parent).
|
|
17
|
+
class Sessions
|
|
18
|
+
# A fork, send or read that couldn't be done; the message says why.
|
|
19
|
+
class Error < StandardError; end
|
|
20
|
+
|
|
21
|
+
# @param host [Host] session_id, cwd, model_name and state_dir
|
|
22
|
+
def initialize(host)
|
|
23
|
+
@host = host
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Start a child session from +messages+ (usually ctx.messages plus
|
|
27
|
+
# more). With no prompt the child waits idle for the user; with one it
|
|
28
|
+
# runs it as its first turn, and counts against session.max_children.
|
|
29
|
+
# Each image a message names is copied into the child, or dropped with
|
|
30
|
+
# a note in its message when its file is gone.
|
|
31
|
+
# @param messages [Array<Hash>] the child's conversation to start with
|
|
32
|
+
# @param title [String, nil] what the lists show for it (the prompt, or
|
|
33
|
+
# the first user message, by default)
|
|
34
|
+
# @param prompt [String, nil] the child's first turn
|
|
35
|
+
# @return [String] the child's id
|
|
36
|
+
# @raise [Error] no session yet, or too many children running
|
|
37
|
+
def fork(messages:, title: nil, prompt: nil)
|
|
38
|
+
parent_id = @host.session_id.call or raise Error, "this session has no id yet"
|
|
39
|
+
state_dir = self.state_dir
|
|
40
|
+
prompt = prompt.to_s.strip.empty? ? nil : prompt.to_s
|
|
41
|
+
check_children(parent_id, state_dir) if prompt
|
|
42
|
+
|
|
43
|
+
child = SessionManager.spawn_session(
|
|
44
|
+
prompt: prompt, working_directory: @host.cwd.call || Dir.pwd, model_name: @host.model_name&.call,
|
|
45
|
+
parent_id: parent_id, messages: Array(messages).map { |message| unfrozen(message) }, title: title,
|
|
46
|
+
images_from: Session.session_dir(parent_id, state_dir: state_dir), state_dir: state_dir
|
|
47
|
+
)
|
|
48
|
+
child.id
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Send +text+ to session +id+ as a user message (it runs as a turn;
|
|
52
|
+
# a stopped session is woken). Waits up to 5 s for its worker, so call
|
|
53
|
+
# it from an anytime command or your own thread, never a tool or hook
|
|
54
|
+
# of a running turn.
|
|
55
|
+
# @return [String] the id of the session it went to
|
|
56
|
+
# @raise [Error] no such session, a REPL owns it, or it wasn't taken
|
|
57
|
+
def send(id, text)
|
|
58
|
+
raise Error, "nothing to send" if text.to_s.strip.empty?
|
|
59
|
+
|
|
60
|
+
state_dir = self.state_dir
|
|
61
|
+
sid = resolve(id, state_dir)
|
|
62
|
+
delivered = SessionManager.deliver_turn(sid, prompt: text.to_s, client_id: "plugin", state_dir: state_dir)
|
|
63
|
+
case delivered[:status]
|
|
64
|
+
when :accepted then sid
|
|
65
|
+
when :refused then raise Error, "session #{sid[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
|
|
66
|
+
when :timeout then raise Error, "session #{sid[0, 8]}'s worker did not answer in time; the message was not sent"
|
|
67
|
+
else raise Error, "the message to session #{sid[0, 8]} could not be written"
|
|
68
|
+
end
|
|
69
|
+
rescue SessionManager::OwnedByTUI
|
|
70
|
+
raise Error, "session #{sid[0, 8]} is open in a chi REPL, which takes no messages from others"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Session +id+ now: from its worker when one runs (with a running turn
|
|
74
|
+
# so far), else as saved.
|
|
75
|
+
# @return [Hash] {id:, title:, status:, parent_id:, running:, messages:}
|
|
76
|
+
# (messages without the system prompt, symbol keys)
|
|
77
|
+
# @raise [Error] no such session
|
|
78
|
+
def read(id)
|
|
79
|
+
state_dir = self.state_dir
|
|
80
|
+
sid = resolve(id, state_dir)
|
|
81
|
+
session = Session.load(sid, state_dir: state_dir)
|
|
82
|
+
messages = session.messages
|
|
83
|
+
running = false
|
|
84
|
+
if (live = live_snapshot(sid, state_dir))
|
|
85
|
+
messages = Array(live["messages"]).map { |message| symbolize_message(message) }
|
|
86
|
+
if (turn = live["current_turn"])
|
|
87
|
+
running = true
|
|
88
|
+
messages += Bridge::TurnAccumulator.messages_of(deep_symbolize(turn))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
{ id: sid, title: session.first_preview.to_s, status: session.status.to_s, parent_id: session.parent_id,
|
|
92
|
+
running: running, messages: without_system_head(messages) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def state_dir = @host.state_dir&.call || Session.default_state_dir
|
|
98
|
+
|
|
99
|
+
def resolve(id, state_dir)
|
|
100
|
+
sid = Session.resolve_id(id.to_s.strip, state_dir: state_dir)
|
|
101
|
+
raise Error, "no session #{id}" unless sid && Session.exist?(sid, state_dir: state_dir)
|
|
102
|
+
|
|
103
|
+
sid
|
|
104
|
+
rescue Session::AmbiguousId, ArgumentError => e
|
|
105
|
+
raise Error, e.message
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def check_children(parent_id, state_dir)
|
|
109
|
+
running = Tools::Delegate.running_children(parent_id, state_dir: state_dir)
|
|
110
|
+
max = Tools::Delegate.max_children
|
|
111
|
+
return if running.size < max
|
|
112
|
+
|
|
113
|
+
raise Error, "#{running.size} child sessions of this session are running (the most is #{max}, " \
|
|
114
|
+
"#{Tools::Delegate::MAX_CHILDREN_KEY}): #{running.map { |s| s[:short_id] }.join(", ")}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def live_snapshot(sid, state_dir)
|
|
118
|
+
BridgeClient.discover(sid, session_dir: Session.session_dir(sid, state_dir: state_dir))&.get_json("snapshot")
|
|
119
|
+
rescue StandardError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def without_system_head(messages)
|
|
124
|
+
first = messages.first
|
|
125
|
+
system = first && first[:role].to_s == "system" && first[:kind].to_s.empty?
|
|
126
|
+
system ? messages.drop(1) : messages
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A message a plugin got frozen (ctx.messages), as a session holds it.
|
|
130
|
+
def unfrozen(message)
|
|
131
|
+
message.to_h { |key, value| [key.to_sym, value.frozen? && value.is_a?(String) ? value.dup : value] }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Symbol keys, as Session.load gives them (its image refs too).
|
|
135
|
+
def symbolize_message(message)
|
|
136
|
+
message = message.transform_keys(&:to_sym)
|
|
137
|
+
message[:images] = message[:images].map { |ref| ref.transform_keys(&:to_sym) } if message[:images].is_a?(Array)
|
|
138
|
+
message
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def deep_symbolize(value)
|
|
142
|
+
case value
|
|
143
|
+
when Hash then value.to_h { |k, v| [k.to_sym, deep_symbolize(v)] }
|
|
144
|
+
when Array then value.map { |v| deep_symbolize(v) }
|
|
145
|
+
else value
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../idle_recap"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Plugin
|
|
8
|
+
# The request behind ctx.ask_model (plan D7): the conversation as a
|
|
9
|
+
# transcript, filtered like the idle recap's (IdleRecap::TranscriptFilter:
|
|
10
|
+
# no system prompt, no tool calls or outputs, no thinking, an image as a
|
|
11
|
+
# line naming it), then the question. One user message holds both, so a
|
|
12
|
+
# conversation that ends on a user turn (a running turn's prompt) doesn't
|
|
13
|
+
# run into the question.
|
|
14
|
+
module SideQuestion
|
|
15
|
+
DEFAULT_SYSTEM = "You answer a question about the conversation below, on the side. Answer briefly and " \
|
|
16
|
+
"don't continue the conversation's task."
|
|
17
|
+
# The most transcript one request sends: the tail is kept.
|
|
18
|
+
MAX_TRANSCRIPT_CHARS = 32_000
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
# @param messages [Array<Hash>] the conversation (symbol or string keys)
|
|
23
|
+
# @param prompt [String] the question
|
|
24
|
+
# @param system [String, nil] instructions; DEFAULT_SYSTEM by default
|
|
25
|
+
# @return [Array<Hash>] chat messages: {role: "system"}, {role: "user"}
|
|
26
|
+
def request(messages:, prompt:, system: nil)
|
|
27
|
+
system = system.to_s.strip.empty? ? DEFAULT_SYSTEM : system.to_s
|
|
28
|
+
transcript = transcript(messages)
|
|
29
|
+
question = prompt.to_s.strip
|
|
30
|
+
user = if transcript.empty?
|
|
31
|
+
question
|
|
32
|
+
else
|
|
33
|
+
"<conversation>\n#{transcript}\n</conversation>\n\n#{question}"
|
|
34
|
+
end
|
|
35
|
+
[{ role: "system", content: system }, { role: "user", content: user }]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# "User: …" and "Assistant: …" paragraphs; the tail when it is long.
|
|
39
|
+
# @return [String]
|
|
40
|
+
def transcript(messages)
|
|
41
|
+
text = stringified(messages).filter_map do |message|
|
|
42
|
+
line = IdleRecap::TranscriptFilter.build([message])
|
|
43
|
+
next if line.strip.empty?
|
|
44
|
+
|
|
45
|
+
"#{message["role"] == "user" ? "User" : "Assistant"}: #{line}"
|
|
46
|
+
end.join("\n\n")
|
|
47
|
+
return text if text.length <= MAX_TRANSCRIPT_CHARS
|
|
48
|
+
|
|
49
|
+
"[earlier conversation left out]\n\n#{text[-MAX_TRANSCRIPT_CHARS..]}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# String keys, as TranscriptFilter reads a saved session.
|
|
53
|
+
def stringified(messages)
|
|
54
|
+
JSON.parse(JSON.generate(Array(messages)))
|
|
55
|
+
rescue StandardError
|
|
56
|
+
[]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Plugin
|
|
5
|
+
# What a plugin tool's block returns to hand the model images too
|
|
6
|
+
# (docs/plugins.md, Returning images):
|
|
7
|
+
#
|
|
8
|
+
# Samagotchi::Plugin::ToolResult.new("took a screenshot", images: [{ path: "/tmp/shot.png" }])
|
|
9
|
+
# Samagotchi::Plugin::ToolResult.new("two frames", images: [{ bytes: png, name: "frame1.png" }, …])
|
|
10
|
+
#
|
|
11
|
+
# It is the text itself (a String), so everything that reads a tool's
|
|
12
|
+
# text keeps working; #images is what ToolRunner attaches. An entry
|
|
13
|
+
# is {path:} or {bytes:, name:} (raw bytes, not base64); one that isn't
|
|
14
|
+
# becomes an "Error:" line for that image, the rest still go.
|
|
15
|
+
class ToolResult < String
|
|
16
|
+
attr_reader :images
|
|
17
|
+
|
|
18
|
+
def initialize(text = "", images: [])
|
|
19
|
+
@images = (images.is_a?(Hash) ? [images] : Array(images)).freeze
|
|
20
|
+
super(text.to_s)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|