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,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "output_formatter"
|
|
4
|
+
require_relative "turn_note"
|
|
5
|
+
require_relative "context_note"
|
|
6
|
+
|
|
7
|
+
module Samagotchi
|
|
8
|
+
# The state around turns that the REPL and a session worker share, kept out
|
|
9
|
+
# of either loop: the pre-turn checkpoint (what !rollback and a failed turn
|
|
10
|
+
# restore), the continue offer after a turn that ran out of iterations, and
|
|
11
|
+
# the checkpoint a continue turn starts from.
|
|
12
|
+
#
|
|
13
|
+
# It edits the conversation only through the Engine's out-of-turn messages
|
|
14
|
+
# API (messages_checkpoint / rollback_to / append_messages); saving the
|
|
15
|
+
# session and telling the user are the host's.
|
|
16
|
+
class TurnFlow
|
|
17
|
+
CONTINUE_COMMAND = "/continue"
|
|
18
|
+
SUMMARY_PROMPT_LIMIT = 600
|
|
19
|
+
SUMMARY_MODEL_LIMIT = 360
|
|
20
|
+
SUMMARY_PARAMS_LIMIT = 80
|
|
21
|
+
SUMMARY_TOOLS_LIMIT = 5
|
|
22
|
+
|
|
23
|
+
# Read an answer to the continue offer.
|
|
24
|
+
# @return [Array(Symbol, String|nil)] [:resume | :abort | :abort_with_reason | :invalid, reason]
|
|
25
|
+
def self.continue_decision(input)
|
|
26
|
+
normalized = input.to_s.strip
|
|
27
|
+
return [:resume, nil] if normalized.empty?
|
|
28
|
+
|
|
29
|
+
lowered = normalized.downcase
|
|
30
|
+
return [:resume, nil] if lowered == CONTINUE_COMMAND || lowered == "yes" || lowered == "y"
|
|
31
|
+
return [:abort, nil] if lowered == "no" || lowered == "n"
|
|
32
|
+
|
|
33
|
+
reason_match = normalized.match(/\A(?:no|n)\s*[,:\-]\s*(.+)\z/i)
|
|
34
|
+
if reason_match
|
|
35
|
+
reason = reason_match[1].to_s.strip
|
|
36
|
+
return [:abort, nil] if reason.empty?
|
|
37
|
+
|
|
38
|
+
return [:abort_with_reason, reason]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
[:invalid, nil]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Hash, nil] the pending continue offer: {context:, no_interrupt:}
|
|
45
|
+
# (context summarizes the interrupted turn for a "no, <reason>")
|
|
46
|
+
attr_reader :offer
|
|
47
|
+
|
|
48
|
+
def initialize(engine:)
|
|
49
|
+
@engine = engine
|
|
50
|
+
@checkpoint = nil
|
|
51
|
+
@continue_checkpoint = nil
|
|
52
|
+
@offer = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def awaiting_continue? = !@offer.nil?
|
|
56
|
+
|
|
57
|
+
# A prompt turn is about to start: remember the conversation before it.
|
|
58
|
+
def before_prompt_turn
|
|
59
|
+
@checkpoint = @engine.messages_checkpoint
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A continue turn is about to start: a cancel goes back to here.
|
|
63
|
+
def before_continue_turn
|
|
64
|
+
@continue_checkpoint = @engine.messages_checkpoint
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Take a turn's result.
|
|
68
|
+
# @param continue [Boolean] it was a continue turn
|
|
69
|
+
# @param no_interrupt [Boolean] the turn ran with no iteration limit to
|
|
70
|
+
# speak of; an offer it makes keeps that for its continue turn
|
|
71
|
+
# @return [Symbol] :completed, :continue_offered, :cancelled (a prompt
|
|
72
|
+
# turn: its partial progress stays, !rollback can undo it) or
|
|
73
|
+
# :continue_cancelled (back to before the continue; the offer stays)
|
|
74
|
+
def after_turn(result, continue: false, no_interrupt: false)
|
|
75
|
+
if result.respond_to?(:canceled?) && result.canceled?
|
|
76
|
+
if continue
|
|
77
|
+
restore(@continue_checkpoint)
|
|
78
|
+
return :continue_cancelled
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The kernel salvaged the completed tool calls and the partial reply
|
|
82
|
+
# into the conversation; without one, the turn leaves nothing.
|
|
83
|
+
restore(@checkpoint) if @checkpoint && !conversation_of(result)
|
|
84
|
+
@offer = nil
|
|
85
|
+
return :cancelled
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if result.respond_to?(:resumable?) && result.resumable?
|
|
89
|
+
@offer = { context: interrupted_turn_context(result), no_interrupt: no_interrupt }
|
|
90
|
+
:continue_offered
|
|
91
|
+
else
|
|
92
|
+
@offer = nil
|
|
93
|
+
@checkpoint = nil
|
|
94
|
+
:completed
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A prompt turn failed: back to the conversation before it, plus the
|
|
99
|
+
# note that says so (TurnNote.failed) in place of an older one at the
|
|
100
|
+
# tail, so the model reads why its last message went unanswered.
|
|
101
|
+
def prompt_turn_failed(note: nil)
|
|
102
|
+
if @checkpoint
|
|
103
|
+
restore(@checkpoint, note: note)
|
|
104
|
+
elsif note
|
|
105
|
+
@engine.rollback_to(TurnNote.replace_trailing(@engine.messages_checkpoint, note))
|
|
106
|
+
end
|
|
107
|
+
@offer = nil
|
|
108
|
+
@checkpoint = nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Answer the offer with no: the interrupted turn is discarded, and a
|
|
112
|
+
# reason (with a summary of that turn) is left for the model to read.
|
|
113
|
+
def abort_continue!(reason: nil)
|
|
114
|
+
restore(@checkpoint) if @checkpoint
|
|
115
|
+
@checkpoint = nil
|
|
116
|
+
@engine.append_messages([{ role: "user", content: reason_message(reason) }]) if reason
|
|
117
|
+
@offer = nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# A new prompt came instead of an answer (a worker takes it): the offer
|
|
121
|
+
# is gone and the partial turn stays, as after a Ctrl-C.
|
|
122
|
+
def drop_offer!
|
|
123
|
+
@offer = nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Restore the pre-turn checkpoint (!rollback after a Ctrl-C). A pending
|
|
127
|
+
# offer goes too: the turn it offered to continue is gone. (The REPL
|
|
128
|
+
# reads every line as an answer while an offer is pending; a worker
|
|
129
|
+
# also takes !rollback then.)
|
|
130
|
+
# @return [Boolean] false when there is none
|
|
131
|
+
def rollback!
|
|
132
|
+
return false unless @checkpoint
|
|
133
|
+
|
|
134
|
+
restore(@checkpoint)
|
|
135
|
+
@checkpoint = nil
|
|
136
|
+
@offer = nil
|
|
137
|
+
true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# The conversation changed outside a turn (!cmd output): rolling back
|
|
141
|
+
# past that would silently drop it.
|
|
142
|
+
def note_conversation_changed
|
|
143
|
+
@checkpoint = nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A reminder turn ran. The checkpoint stays only for a pending offer.
|
|
147
|
+
def after_reminder_turn
|
|
148
|
+
@checkpoint = nil unless awaiting_continue?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Back to +checkpoint+, keeping the context notes that arrived since:
|
|
154
|
+
# notes land between turns, after the checkpoint was taken, and a
|
|
155
|
+
# rollback must not drop them. One rollback, +note+ included.
|
|
156
|
+
def restore(checkpoint, note: nil)
|
|
157
|
+
kept = Array(checkpoint).filter_map { |m| m[:note_id] }
|
|
158
|
+
arrived = @engine.messages_checkpoint.select { |m| ContextNote.note?(m) && !kept.include?(m[:note_id]) }
|
|
159
|
+
restored = Array(checkpoint) + arrived
|
|
160
|
+
restored = TurnNote.replace_trailing(restored, note) if note
|
|
161
|
+
@engine.rollback_to(restored)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def conversation_of(result)
|
|
165
|
+
result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def interrupted_turn_context(result)
|
|
169
|
+
messages = interrupted_turn_messages(conversation_of(result))
|
|
170
|
+
{
|
|
171
|
+
original_prompt: preview_text(messages.find { |m| m[:role] == "user" }&.dig(:content), SUMMARY_PROMPT_LIMIT),
|
|
172
|
+
tool_trace: tool_trace(result),
|
|
173
|
+
last_model_intent: preview_text(model_intent(messages.reverse.find { |m| m[:role] == "model" }&.dig(:content)), SUMMARY_MODEL_LIMIT)
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The messages the turn added after the checkpoint (all of them after an
|
|
178
|
+
# empty one: a new session's first turn). The prefix is matched by role,
|
|
179
|
+
# not content: the kernel returns earlier model messages without their
|
|
180
|
+
# thinking, so they differ from the saved ones, one for one.
|
|
181
|
+
def interrupted_turn_messages(conversation)
|
|
182
|
+
checkpoint = Array(@checkpoint)
|
|
183
|
+
conversation = Array(conversation)
|
|
184
|
+
return [] if conversation.length < checkpoint.length
|
|
185
|
+
return [] unless conversation.first(checkpoint.length).map { |m| m[:role] } == checkpoint.map { |m| m[:role] }
|
|
186
|
+
|
|
187
|
+
conversation[checkpoint.length..] || []
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def tool_trace(result)
|
|
191
|
+
activities = result.respond_to?(:tool_activity) ? Array(result.tool_activity) : []
|
|
192
|
+
activities.last(SUMMARY_TOOLS_LIMIT).map do |activity|
|
|
193
|
+
tool = activity[:tool].to_s.strip
|
|
194
|
+
status = activity[:status].to_s.strip
|
|
195
|
+
params = preview_text(activity[:params], SUMMARY_PARAMS_LIMIT)
|
|
196
|
+
parts = [tool]
|
|
197
|
+
parts << "status=#{status}" unless status.empty?
|
|
198
|
+
parts << "params=#{params}" unless params.empty?
|
|
199
|
+
parts.join(" ")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def reason_message(reason)
|
|
204
|
+
context = @offer && @offer[:context]
|
|
205
|
+
lines = ["I chose not to continue the interrupted turn because: #{reason}"]
|
|
206
|
+
lines << ""
|
|
207
|
+
lines << "Interrupted turn summary:"
|
|
208
|
+
original_prompt = context && context[:original_prompt]
|
|
209
|
+
lines << "- original_prompt: #{original_prompt.to_s.empty? ? "(unavailable)" : original_prompt}"
|
|
210
|
+
|
|
211
|
+
tool_trace = context ? Array(context[:tool_trace]) : []
|
|
212
|
+
if tool_trace.empty?
|
|
213
|
+
lines << "- interrupted_tools: (none)"
|
|
214
|
+
else
|
|
215
|
+
lines << "- interrupted_tools: #{tool_trace.join("; ")}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
model_intent = context && context[:last_model_intent]
|
|
219
|
+
lines << "- last_model_intent: #{model_intent.to_s.empty? ? "(unavailable)" : model_intent}"
|
|
220
|
+
lines << ""
|
|
221
|
+
lines << "Please keep the original prompt context. If my next message does not provide a clear replacement request, ask what we should do instead."
|
|
222
|
+
lines.join("\n")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# The model's words without its thinking and tool-call markup; when it
|
|
226
|
+
# wrote nothing else, its thinking says what it was about to do.
|
|
227
|
+
def model_intent(content)
|
|
228
|
+
text = OutputFormatter.strip(content)
|
|
229
|
+
return text unless text.empty?
|
|
230
|
+
|
|
231
|
+
OutputFormatter.strip(content.to_s.gsub(%r{</?think>}, ""))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def preview_text(text, limit)
|
|
235
|
+
normalized = text.to_s.gsub(/\s+/, " ").strip
|
|
236
|
+
return "" if normalized.empty?
|
|
237
|
+
return normalized if normalized.length <= limit
|
|
238
|
+
|
|
239
|
+
normalized[0, limit].rstrip + "..."
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# The one-line system note a turn leaves in the conversation when it ends
|
|
5
|
+
# without an answer: failed before the model replied, cancelled, or over
|
|
6
|
+
# with nothing visible. The UIs show these ends live (`turn failed …`, a
|
|
7
|
+
# cancel line, `(the model returned an empty answer)`), but the model saw
|
|
8
|
+
# none of that on its next turn: the session held an unanswered user
|
|
9
|
+
# message and no reason. A tail system message, like a reminder or a
|
|
10
|
+
# context note, keeps the prompt cache and is hidden by the UIs (they
|
|
11
|
+
# show system messages only when `kind: note`).
|
|
12
|
+
module TurnNote
|
|
13
|
+
KIND = "turn_note"
|
|
14
|
+
OPEN = "[SYSTEM: "
|
|
15
|
+
CLOSE = "]"
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# @param summary [String] the error's one-line summary
|
|
20
|
+
# @param restored [Boolean] the prompt went back to its sender (the
|
|
21
|
+
# failed user message is not in the conversation any more)
|
|
22
|
+
# @param continued [Boolean] it was a continue turn (no user message)
|
|
23
|
+
def failed(summary, restored: false, continued: false)
|
|
24
|
+
tail = if restored then "The message went back to the user, who may send it again."
|
|
25
|
+
elsif continued then "The continued turn stopped there."
|
|
26
|
+
else "The user's last message was not answered."
|
|
27
|
+
end
|
|
28
|
+
message("the previous turn failed before any answer: #{one_line(summary)}. #{tail}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param reason [Symbol, String, nil] the cancel reason (:ctrl_c, :user…)
|
|
32
|
+
# @param seconds [Numeric, nil] how long the turn had run
|
|
33
|
+
# @param shown [Boolean] visible text had streamed (an `[interrupted]`
|
|
34
|
+
# model message precedes this note)
|
|
35
|
+
def cancelled(reason, seconds: nil, shown: false)
|
|
36
|
+
why = reason.to_s.empty? ? "" : " (#{reason.to_s.tr("_", "-")})"
|
|
37
|
+
after = seconds ? " after #{seconds.round}s" : ""
|
|
38
|
+
what = shown ? "the answer above ends where it was cut off." : "no answer had been shown."
|
|
39
|
+
message("the previous turn was cancelled#{why}#{after}; #{what}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty
|
|
43
|
+
message("the previous turn ended with no visible answer (thinking only, or nothing). The user's last message is still unanswered.")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def note?(entry)
|
|
47
|
+
return false unless entry.respond_to?(:[])
|
|
48
|
+
|
|
49
|
+
(entry[:kind] || entry["kind"]).to_s == KIND
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def message(text)
|
|
53
|
+
{ role: "system", content: "#{OPEN}#{text}#{CLOSE}", kind: KIND }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# +messages+ plus +note+, in place of a note already at the tail (behind
|
|
57
|
+
# context notes at most): failed retries leave one note, not a pile.
|
|
58
|
+
def replace_trailing(messages, note)
|
|
59
|
+
list = Array(messages).dup
|
|
60
|
+
index = list.length - 1
|
|
61
|
+
index -= 1 while index >= 0 && (list[index][:role] || list[index]["role"]).to_s == "system" && !note?(list[index])
|
|
62
|
+
list.delete_at(index) if index >= 0 && note?(list[index])
|
|
63
|
+
list << note
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The last message is a reply cut short (`[interrupted]`).
|
|
67
|
+
def interrupted_tail?(messages)
|
|
68
|
+
last = Array(messages).last
|
|
69
|
+
!!(last && (last[:interrupted] || last["interrupted"]))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def one_line(text)
|
|
73
|
+
text.to_s.gsub(/\s+/, " ").strip
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A running count of one turn's tool calls, for the one-line tally a long,
|
|
5
|
+
# tool-heavy turn shows under its activity row:
|
|
6
|
+
#
|
|
7
|
+
# 12 tool calls (2 failed) · execute ×7 · read_file ×3 · edit ×2 · last: execute command=rspec
|
|
8
|
+
#
|
|
9
|
+
# Built from the turn's :tool_call_started / :tool_call_completed events
|
|
10
|
+
# (or a joined turn's snapshot parts), with no model call. The web builds
|
|
11
|
+
# the same text from its activity rows (public/tally.js); both follow
|
|
12
|
+
# spec/shared/tally_matrix.json.
|
|
13
|
+
class TurnTally
|
|
14
|
+
# Below this many calls the "running <tool>…" row says enough.
|
|
15
|
+
MIN_CALLS = 3
|
|
16
|
+
TOP_TOOLS = 3
|
|
17
|
+
SEPARATOR = " · "
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
reset
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [TurnTally] self
|
|
24
|
+
def reset
|
|
25
|
+
@calls = {}
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def count
|
|
30
|
+
@calls.size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param key [Object] one call's identity within the turn (the event's
|
|
34
|
+
# [iteration, call_index])
|
|
35
|
+
def started(key:, tool:, params: nil)
|
|
36
|
+
call = (@calls[key] ||= { tool: tool.to_s, params: "", status: "running" })
|
|
37
|
+
call[:tool] = tool.to_s unless tool.to_s.empty?
|
|
38
|
+
call[:params] = params.to_s unless params.nil?
|
|
39
|
+
call[:status] = "running"
|
|
40
|
+
call
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A completion with no start seen still counts as a call (as the web's
|
|
44
|
+
# activity rows do). Only "error" is a failure: a call a guardrail or an
|
|
45
|
+
# approval blocked still counts as a call, not as a failed one.
|
|
46
|
+
def completed(key:, tool:, status:, params: nil)
|
|
47
|
+
call = @calls[key] || started(key: key, tool: tool, params: params)
|
|
48
|
+
call[:params] = params.to_s if params
|
|
49
|
+
call[:status] = status.to_s == "error" ? "error" : "ok"
|
|
50
|
+
call
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Pick up a turn joined mid-way: its snapshot's tool parts.
|
|
54
|
+
# @param parts [Array<Hash>] the Bridge snapshot's current_turn parts
|
|
55
|
+
def seed(parts)
|
|
56
|
+
Array(parts).each do |part|
|
|
57
|
+
part = part.transform_keys(&:to_sym)
|
|
58
|
+
next unless part[:kind].to_s == "tool"
|
|
59
|
+
|
|
60
|
+
key = [part[:iteration].to_i, part[:call_index].to_i]
|
|
61
|
+
started(key: key, tool: part[:tool], params: part[:params])
|
|
62
|
+
completed(key: key, tool: part[:tool], status: part[:status]) unless part[:status].to_s == "running"
|
|
63
|
+
end
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @param width [Integer, nil] cut the text to this many characters
|
|
68
|
+
# @param last [Boolean] end with the last call and its params
|
|
69
|
+
# @return [String, nil] nil below MIN_CALLS
|
|
70
|
+
def text(width: nil, last: true)
|
|
71
|
+
self.class.format(@calls.values, last: last, width: width)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param calls [Array<Hash>] {tool:, params:, status:} in call order
|
|
75
|
+
def self.format(calls, last: true, width: nil)
|
|
76
|
+
return nil if calls.size < MIN_CALLS
|
|
77
|
+
|
|
78
|
+
failed = calls.count { |c| c[:status] == "error" }
|
|
79
|
+
head = "#{calls.size} tool calls"
|
|
80
|
+
head += " (#{failed} failed)" if failed.positive?
|
|
81
|
+
counts = calls.each_with_object(Hash.new(0)) { |c, h| h[c[:tool]] += 1 }
|
|
82
|
+
# Ties go to the tool used first (a Hash keeps insertion order, and
|
|
83
|
+
# sort_by is not stable, so the index breaks them).
|
|
84
|
+
top = counts.each_with_index.sort_by { |(_, n), i| [-n, i] }.first(TOP_TOOLS)
|
|
85
|
+
fields = [head] + top.map { |(tool, n), _| "#{tool} ×#{n}" }
|
|
86
|
+
if last
|
|
87
|
+
call = calls.last
|
|
88
|
+
params = call[:params].to_s.gsub(/\s+/, " ").strip
|
|
89
|
+
fields << "last: #{[call[:tool], params].reject(&:empty?).join(" ")}"
|
|
90
|
+
end
|
|
91
|
+
cut(fields.join(SEPARATOR), width)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.cut(text, width)
|
|
95
|
+
return text if width.nil? || text.length <= width
|
|
96
|
+
return text[0, width] if width < 2
|
|
97
|
+
|
|
98
|
+
"#{text[0, width - 1]}…"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require_relative "image_store"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# What a turn's loops need to send images, set on the kernel by the
|
|
8
|
+
# Engine for one turn:
|
|
9
|
+
# capability VisionSupport::Answer (nil: unknown, send), or a callable
|
|
10
|
+
# that answers it on first need (a probe only when a turn
|
|
11
|
+
# has images)
|
|
12
|
+
# session_dir where the session's images/ are (refs resolve against it)
|
|
13
|
+
# limits ImageStore::Limits
|
|
14
|
+
# resizer ImageResizer for images a tool returns (nil: detect)
|
|
15
|
+
class VisionContext
|
|
16
|
+
attr_reader :session_dir, :limits, :resizer
|
|
17
|
+
|
|
18
|
+
def initialize(capability: nil, session_dir: nil, limits: ImageStore::Limits.from_config, resizer: nil)
|
|
19
|
+
@capability = capability
|
|
20
|
+
@session_dir = session_dir
|
|
21
|
+
@limits = limits
|
|
22
|
+
@resizer = resizer
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capability
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
@capability = @capability.call if @capability.respond_to?(:call)
|
|
29
|
+
@capability
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with(**changes)
|
|
34
|
+
self.class.new(capability: @capability, session_dir: session_dir, limits: limits, resizer: resizer, **changes)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# False only when the model is known not to see images.
|
|
38
|
+
def sendable? = capability.nil? || capability.value != false
|
|
39
|
+
|
|
40
|
+
# Why images aren't sent, for a line or a refusal.
|
|
41
|
+
def refusal_reason = capability&.reason || ImagePlan::CANT_SEE
|
|
42
|
+
|
|
43
|
+
# The ref's base64, or nil when it isn't a valid ref of this session.
|
|
44
|
+
def base64(ref)
|
|
45
|
+
return nil unless session_dir && ImageStore.valid_ref?(session_dir, ref)
|
|
46
|
+
|
|
47
|
+
ImageStore.base64(session_dir, ref)
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def data_uri(ref)
|
|
53
|
+
data = base64(ref)
|
|
54
|
+
data && "data:#{ref[:mime] || ref["mime"]};base64,#{data}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Stores an image a tool read or returned (source "tool", from a +path+
|
|
58
|
+
# or raw +bytes+) and returns its ref.
|
|
59
|
+
def ingest(path = nil, name: nil, bytes: nil)
|
|
60
|
+
raise ImageStore::Error, "no session to keep the image in" unless session_dir
|
|
61
|
+
|
|
62
|
+
ImageStore.ingest(session_dir, path: path, bytes: bytes, name: name, source: "tool", limits: limits,
|
|
63
|
+
resizer: resizer || ImageResizer.detect)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Which images of a conversation one request sends. Every image that is
|
|
68
|
+
# not sent becomes a placeholder line: all of them when the model can't
|
|
69
|
+
# see images, the older ones past limits.max_per_request (the newest go),
|
|
70
|
+
# and any whose file is gone.
|
|
71
|
+
class ImagePlan
|
|
72
|
+
Item = Data.define(:ref, :data, :placeholder) do
|
|
73
|
+
def sent? = !data.nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
ROLES = %w[user tool_response].freeze
|
|
77
|
+
CANT_SEE = "this model can't see images"
|
|
78
|
+
# Stands for the server's media marker in a native prompt until
|
|
79
|
+
# Client#complete swaps in the live one (random, so no typed text has it).
|
|
80
|
+
NATIVE_PLACEHOLDER = "<__chi_image_#{SecureRandom.hex(8)}__>".freeze
|
|
81
|
+
|
|
82
|
+
# @param conversation [Array<Hash>] engine-format messages
|
|
83
|
+
# @param vision [VisionContext, nil]
|
|
84
|
+
# @param data [Symbol] :base64 (native) or :data_uri (chat)
|
|
85
|
+
def initialize(conversation, vision, data: :data_uri)
|
|
86
|
+
@vision = vision
|
|
87
|
+
@data = data
|
|
88
|
+
occurrences = []
|
|
89
|
+
conversation.each_with_index do |entry, index|
|
|
90
|
+
next unless ROLES.include?(entry[:role].to_s)
|
|
91
|
+
|
|
92
|
+
Array(entry[:images]).each_index { |position| occurrences << [index, position] }
|
|
93
|
+
end
|
|
94
|
+
@limit = vision&.limits&.max_per_request || ImageStore::Limits.from_config.max_per_request
|
|
95
|
+
@sent = occurrences.last(@limit)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The Items for conversation[+index+] (none when it has no images).
|
|
99
|
+
def items(entry, index)
|
|
100
|
+
return [] unless ROLES.include?(entry[:role].to_s)
|
|
101
|
+
|
|
102
|
+
Array(entry[:images]).each_with_index.map do |raw, position|
|
|
103
|
+
ref = ImageStore.symbolize(raw)
|
|
104
|
+
reason = skip_reason(index, position)
|
|
105
|
+
data = reason ? nil : fetch(ref)
|
|
106
|
+
reason ||= "the image file is missing" unless data
|
|
107
|
+
Item.new(ref: ref, data: data, placeholder: reason && ImageRef.placeholder(ref, reason))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Estimated tokens of the images a request sends (ImageRef.estimated_tokens).
|
|
112
|
+
def self.estimated_tokens(conversation)
|
|
113
|
+
conversation.sum do |entry|
|
|
114
|
+
ROLES.include?(entry[:role].to_s) ? Array(entry[:images]).sum { |ref| ImageRef.estimated_tokens(ImageStore.symbolize(ref)) } : 0
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def skip_reason(index, position)
|
|
121
|
+
return "no session images here" if @vision.nil?
|
|
122
|
+
return CANT_SEE unless @vision.sendable?
|
|
123
|
+
return "only the newest #{@limit} images are sent" unless @sent.include?([index, position])
|
|
124
|
+
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fetch(ref)
|
|
129
|
+
@data == :base64 ? @vision.base64(ref) : @vision.data_uri(ref)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "config"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# Whether a model can take images, asked before a turn with images is
|
|
7
|
+
# sent: true (send), false (refuse, with a reason), or nil (unknown: send,
|
|
8
|
+
# and map the provider's error if it says no).
|
|
9
|
+
#
|
|
10
|
+
# First match wins:
|
|
11
|
+
# 1. config: models.<name>.vision, then hosts.<name>.vision
|
|
12
|
+
# 2. a native host: llama.cpp only, and it must give a media marker, have
|
|
13
|
+
# vision in /props modalities, and use the profile's image template
|
|
14
|
+
# (the prompt can't carry an image any other way)
|
|
15
|
+
# 3. a chat host: a local llama.cpp's /props modalities, then the host's
|
|
16
|
+
# model list (input_modalities / "multimodal")
|
|
17
|
+
# 4. unknown
|
|
18
|
+
module VisionSupport
|
|
19
|
+
Answer = Data.define(:value, :reason) do
|
|
20
|
+
def yes? = value == true
|
|
21
|
+
def no? = value == false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param target [HostRegistry::ModelTarget]
|
|
25
|
+
# @param profile [ModelProfile, nil] the model's prompt profile (native)
|
|
26
|
+
# @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
|
|
27
|
+
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
28
|
+
# @return [Answer]
|
|
29
|
+
def self.for(target, profile: nil, adapter: nil, models: nil)
|
|
30
|
+
entry = target.entry
|
|
31
|
+
configured = configured(target, entry, models)
|
|
32
|
+
return configured if configured&.no?
|
|
33
|
+
|
|
34
|
+
if entry.chat?
|
|
35
|
+
configured || chat(target, entry, adapter)
|
|
36
|
+
else
|
|
37
|
+
native(target, entry, profile, trust_config: configured&.yes?)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The media marker the running llama.cpp wants in the prompt, or nil.
|
|
42
|
+
def self.media_marker(props)
|
|
43
|
+
marker = props&.answered? && props.body.is_a?(Hash) ? props.body["media_marker"] : nil
|
|
44
|
+
marker.is_a?(String) && !marker.empty? ? marker : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.configured(target, entry, models)
|
|
48
|
+
models ||= begin
|
|
49
|
+
ConfigFile.model_settings
|
|
50
|
+
rescue StandardError
|
|
51
|
+
{}
|
|
52
|
+
end
|
|
53
|
+
[target.model, target.bare_model].map { |name| name.to_s.strip.downcase }.uniq.each do |key|
|
|
54
|
+
value = models.dig(key, :vision)
|
|
55
|
+
next if value.nil?
|
|
56
|
+
|
|
57
|
+
return Answer.new(value: value, reason: value ? nil : "models: #{key} sets vision: false")
|
|
58
|
+
end
|
|
59
|
+
return nil if entry.vision.nil?
|
|
60
|
+
|
|
61
|
+
Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.native(target, entry, profile, trust_config:)
|
|
65
|
+
client = target.client
|
|
66
|
+
transport = client.respond_to?(:transport) ? client.transport.name : :llama_cpp
|
|
67
|
+
return no("#{transport} hosts take no images (only llama.cpp does)") unless transport == :llama_cpp
|
|
68
|
+
|
|
69
|
+
props = client.respond_to?(:server_props) ? client.server_props(model: target.bare_model) : nil
|
|
70
|
+
return no("can't reach /props for the media marker") unless props&.answered?
|
|
71
|
+
|
|
72
|
+
body = props.body.is_a?(Hash) ? props.body : {}
|
|
73
|
+
unless trust_config || body.dig("modalities", "vision") == true
|
|
74
|
+
return no("the server has no vision model loaded (start llama.cpp with --mmproj)")
|
|
75
|
+
end
|
|
76
|
+
return no("the server gives no media marker (update llama.cpp)") unless media_marker(props)
|
|
77
|
+
return no("profile #{profile&.name || "?"} has no image template yet") unless profile&.image_template
|
|
78
|
+
|
|
79
|
+
open = profile.image_open_token
|
|
80
|
+
unless open.empty? || body["chat_template"].to_s.include?(open)
|
|
81
|
+
return no("the server's chat template doesn't use #{open} (profile #{profile.name})")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
Answer.new(value: true, reason: nil)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.chat(target, entry, adapter)
|
|
88
|
+
unless entry.remote?
|
|
89
|
+
props = entry.client.respond_to?(:server_props) ? entry.client.server_props(model: target.bare_model) : nil
|
|
90
|
+
if props&.answered? && props.body.is_a?(Hash) && props.body.key?("modalities")
|
|
91
|
+
vision = props.body.dig("modalities", "vision") == true
|
|
92
|
+
return vision ? Answer.new(value: true, reason: nil) : no("the server has no vision model loaded (start llama.cpp with --mmproj)")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
listed = adapter.respond_to?(:image_input) ? adapter.image_input(model: target.bare_model) : nil
|
|
97
|
+
return Answer.new(value: true, reason: nil) if listed == true
|
|
98
|
+
return no("host #{entry.name} lists #{target.bare_model} as text-only") if listed == false
|
|
99
|
+
|
|
100
|
+
Answer.new(value: nil, reason: nil)
|
|
101
|
+
rescue StandardError
|
|
102
|
+
Answer.new(value: nil, reason: nil)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.no(reason) = Answer.new(value: false, reason: reason)
|
|
106
|
+
|
|
107
|
+
private_class_method :configured, :native, :chat, :no
|
|
108
|
+
end
|
|
109
|
+
end
|