samagotchi 0.50.0 → 0.52.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 +4 -4
- data/CHANGELOG.md +89 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -11
- data/docs/configuration.md +63 -21
- data/docs/hooks.md +3 -2
- data/docs/internals/engine.md +4 -2
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +10 -5
- data/docs/testing.md +6 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bridge.rb +29 -17
- data/lib/samagotchi/bridge_client.rb +3 -1
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +56 -0
- data/lib/samagotchi/engine.rb +48 -17
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +21 -13
- data/lib/samagotchi/kernel_loop.rb +28 -2
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -7
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +92 -21
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +41 -3
- data/lib/samagotchi/session_inbox.rb +36 -7
- data/lib/samagotchi/session_manager.rb +21 -10
- data/lib/samagotchi/session_metrics.rb +25 -6
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/steer_cut.rb +21 -10
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +69 -4
- data/lib/samagotchi/terminal_ui.rb +56 -8
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tool_activity.rb +3 -0
- data/lib/samagotchi/tool_runner.rb +2 -0
- data/lib/samagotchi/tools/builtin_calls.rb +33 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +59 -7
- data/lib/samagotchi/web/message_parts.rb +3 -0
- data/lib/samagotchi/web/public/activity.js +6 -0
- data/lib/samagotchi/web/public/app.js +199 -48
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +25 -20
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/session_title.js +26 -0
- data/lib/samagotchi/web/public/strip.js +4 -6
- data/lib/samagotchi/web/public/thinking_chip.js +169 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +26 -2
- data/lib/samagotchi/web/public/turn_html.js +3 -1
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +44 -43
- data/lib/samagotchi/worker_inbound.rb +10 -7
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- data/lib/samagotchi.rb +1 -0
- metadata +13 -1
|
@@ -5,6 +5,7 @@ require_relative "../served_model"
|
|
|
5
5
|
require_relative "../image_store"
|
|
6
6
|
require_relative "../turn_note"
|
|
7
7
|
require_relative "../steer"
|
|
8
|
+
require_relative "../steer_cut"
|
|
8
9
|
require_relative "../memory_bundle/index_size"
|
|
9
10
|
require_relative "../prompt_note"
|
|
10
11
|
|
|
@@ -31,13 +32,17 @@ module Samagotchi
|
|
|
31
32
|
|
|
32
33
|
# What a tool line says the call did: the model's description of a
|
|
33
34
|
# command (": List the specs") in place of its cut params, else the
|
|
34
|
-
# params, dim
|
|
35
|
+
# params, dim; then, dim, the name the model called it by when that was
|
|
36
|
+
# an alias's (" (called as bash)").
|
|
35
37
|
def tool_params_suffix(activity)
|
|
36
38
|
description = activity[:description].to_s.strip
|
|
37
|
-
return ": #{description}" unless description.empty?
|
|
38
|
-
|
|
39
39
|
params = activity[:params].to_s.strip
|
|
40
|
-
|
|
40
|
+
suffix = if !description.empty? then ": #{description}"
|
|
41
|
+
elsif !params.empty? then " #{paint(params, 90)}"
|
|
42
|
+
else ""
|
|
43
|
+
end
|
|
44
|
+
called_as = activity[:called_as].to_s
|
|
45
|
+
called_as.empty? ? suffix : "#{suffix}#{paint(" (called as #{called_as})", 90)}"
|
|
41
46
|
end
|
|
42
47
|
|
|
43
48
|
# Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
|
|
@@ -91,6 +96,49 @@ module Samagotchi
|
|
|
91
96
|
paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
|
|
92
97
|
end
|
|
93
98
|
|
|
99
|
+
# What a line submitted at the open prompt says became of it: a plain
|
|
100
|
+
# line goes in at the running turn's next step boundary (nothing cuts
|
|
101
|
+
# unless the message asked for it), /cut's cutting now or once the
|
|
102
|
+
# thinking passes steer.cut_after, and /queue's waiting for the end.
|
|
103
|
+
# Both TUIs and chi send --cut print the same words
|
|
104
|
+
# (SteerCut#cut_for_steer's answer).
|
|
105
|
+
NEXT_STEP_NOTE = "(goes in at the next step)"
|
|
106
|
+
CUT_NOW_NOTE = "(cut in now)"
|
|
107
|
+
CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
|
|
108
|
+
CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
|
|
109
|
+
STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
|
|
110
|
+
# The UIs' own words for a delivery, listed for Tab and /help only
|
|
111
|
+
# (a local: entry, so no command or bundle may use the names).
|
|
112
|
+
CUT_COMMAND = "/cut"
|
|
113
|
+
QUEUE_COMMAND = "/queue"
|
|
114
|
+
# The word, then any whitespace and the text (as the registry's
|
|
115
|
+
# entries match them, so a line Tab or /help knows is never a prompt).
|
|
116
|
+
STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
|
|
117
|
+
|
|
118
|
+
# @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
|
|
119
|
+
# @return [String] the dim line to show for a /cut
|
|
120
|
+
def self.steer_cut_note(outcome)
|
|
121
|
+
case outcome
|
|
122
|
+
when :now then CUT_NOW_NOTE
|
|
123
|
+
when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
|
|
124
|
+
else CUT_OFF_NOTE
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The same words, for a UI that includes the module.
|
|
129
|
+
def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
|
|
130
|
+
|
|
131
|
+
# The choice a mid-turn line carries, as [:cut, "TEXT"] or
|
|
132
|
+
# [:queue, "TEXT"]; nil for any other line. The text is "" when the
|
|
133
|
+
# line named the choice without one.
|
|
134
|
+
# @param line [String, nil]
|
|
135
|
+
# @return [Array(Symbol, String), nil]
|
|
136
|
+
def steer_choice(line)
|
|
137
|
+
match = STEER_CHOICE.match(line.to_s.strip) or return nil
|
|
138
|
+
|
|
139
|
+
[match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
|
|
140
|
+
end
|
|
141
|
+
|
|
94
142
|
# "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
|
|
95
143
|
# off)": a batch of LLM context edits went in (LLMContextNotice builds
|
|
96
144
|
# the line; the web prints the same), dim like the retry row.
|
|
@@ -516,6 +564,10 @@ module Samagotchi
|
|
|
516
564
|
by_tool = snapshot[:tool_calls_by_tool].sort_by { |_k, v| -v }
|
|
517
565
|
lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
518
566
|
end
|
|
567
|
+
unless snapshot[:tool_calls_aliased].to_a.empty?
|
|
568
|
+
aliased = snapshot[:tool_calls_aliased].sort_by { |_k, v| -v }
|
|
569
|
+
lines << " called as: #{aliased.map { |k, v| "#{k}=#{v}" }.join(", ")}"
|
|
570
|
+
end
|
|
519
571
|
lines << "iterations: #{snapshot[:iterations_total]}"
|
|
520
572
|
# Summed over every request: each prompt is sent in full again.
|
|
521
573
|
lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
|
|
@@ -544,6 +596,8 @@ module Samagotchi
|
|
|
544
596
|
lines << "model notes: #{notes}" unless notes.empty?
|
|
545
597
|
llm_context = llm_context_stats_text(snapshot[:llm_context])
|
|
546
598
|
lines << "llm context: #{llm_context}" if llm_context
|
|
599
|
+
thinking = thinking_stats_text(snapshot[:thinking])
|
|
600
|
+
lines << "thinking: #{thinking}" if thinking
|
|
547
601
|
if snapshot[:served_model]
|
|
548
602
|
asked = snapshot[:served_model_for]
|
|
549
603
|
differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
|
|
@@ -583,6 +637,17 @@ module Samagotchi
|
|
|
583
637
|
"budget #{budget} (#{get.call(:budget_where)})"
|
|
584
638
|
end
|
|
585
639
|
|
|
640
|
+
# "low (session)", "default" for /stats (Thinking::Explained#summary;
|
|
641
|
+
# symbol or string keys, the attached TUI's come as JSON); nil
|
|
642
|
+
# without one.
|
|
643
|
+
def thinking_stats_text(summary)
|
|
644
|
+
return nil unless summary.is_a?(Hash)
|
|
645
|
+
|
|
646
|
+
level = summary[:level] || summary["level"]
|
|
647
|
+
source = summary[:source] || summary["source"]
|
|
648
|
+
source ? "#{level} (#{source})" : level
|
|
649
|
+
end
|
|
650
|
+
|
|
586
651
|
# ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
|
|
587
652
|
# 212" for the tokens line; the counts a server didn't report are left
|
|
588
653
|
# out (re-prefilled: SessionMetrics' reprefill_sum).
|
|
@@ -90,9 +90,12 @@ module Samagotchi
|
|
|
90
90
|
# however the REPL ends, saves no memories and writes no recap
|
|
91
91
|
# @param llm_context [Hash{Symbol => String}] --llm-context* as typed
|
|
92
92
|
# (LLMContextOverride.update): set on the new or resumed session
|
|
93
|
+
# @param thinking [String, nil] --thinking as typed: the new or resumed
|
|
94
|
+
# session's own level (default unsets it)
|
|
93
95
|
def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
|
|
94
|
-
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {})
|
|
96
|
+
spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil)
|
|
95
97
|
@scratch = scratch
|
|
98
|
+
@thinking_word = thinking
|
|
96
99
|
@prompt = prompt
|
|
97
100
|
@llm_context_words = llm_context
|
|
98
101
|
@default_model_name = ModelProfile.required_model_name(nil)
|
|
@@ -246,8 +249,10 @@ module Samagotchi
|
|
|
246
249
|
))
|
|
247
250
|
# --llm-context*: the session's own values, over a resumed one's.
|
|
248
251
|
# Saved at once on a resumed one: leaving before a turn keeps them.
|
|
249
|
-
|
|
250
|
-
|
|
252
|
+
# --thinking: the session's own level, saved at once on a resumed one.
|
|
253
|
+
unless @llm_context_words.empty? && @thinking_word.nil?
|
|
254
|
+
session.llm_context = LLMContextOverride.update(session.llm_context, @llm_context_words) unless @llm_context_words.empty?
|
|
255
|
+
session.thinking = Thinking.session_level(@thinking_word) if @thinking_word
|
|
251
256
|
session.save if @resume_session
|
|
252
257
|
end
|
|
253
258
|
claim_session!(session.id) unless @owner_lock
|
|
@@ -752,6 +757,14 @@ module Samagotchi
|
|
|
752
757
|
def run_input_line(session, input)
|
|
753
758
|
return if input.empty?
|
|
754
759
|
|
|
760
|
+
# /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
|
|
761
|
+
# the prompt both run the text as a normal turn, as their usage says.
|
|
762
|
+
if (choice = steer_choice(input))
|
|
763
|
+
return @surface.commit(STEER_CHOICE_USAGE) if choice.last.empty?
|
|
764
|
+
|
|
765
|
+
input = choice.last
|
|
766
|
+
end
|
|
767
|
+
|
|
755
768
|
# /model, /models, !rollback, !cmd, /continue (shared with workers);
|
|
756
769
|
# an anytime command's cards print as it shows them (btw's
|
|
757
770
|
# "thinking…" before the answer).
|
|
@@ -1083,6 +1096,10 @@ module Samagotchi
|
|
|
1083
1096
|
# plugin's /btw, /model alone) starts now on its own thread; one that
|
|
1084
1097
|
# runs after the turn (/model X, !cmd) waits in the queue, in arrival
|
|
1085
1098
|
# order with the other lines that wait; others go back in the prompt.
|
|
1099
|
+
#
|
|
1100
|
+
# A plain line goes in at the running turn's next step boundary and
|
|
1101
|
+
# never cuts; /cut TEXT asks for a cut, /queue TEXT waits for the turn
|
|
1102
|
+
# to end and runs as a turn of its own.
|
|
1086
1103
|
# @return [Boolean, :back] whether the turn took it, :back to put it back
|
|
1087
1104
|
def steer_line(line)
|
|
1088
1105
|
local = local_command(line) unless line.nil?
|
|
@@ -1091,6 +1108,12 @@ module Samagotchi
|
|
|
1091
1108
|
return detach_note if local == :detach
|
|
1092
1109
|
return false if @active_cancel_controller&.cancelled?
|
|
1093
1110
|
|
|
1111
|
+
# Before every command check: /cut and /queue are the UIs' own words
|
|
1112
|
+
# for a delivery (they are listed for Tab and /help, never run).
|
|
1113
|
+
if (choice = steer_choice(line))
|
|
1114
|
+
return submit_steer_choice(*choice)
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1094
1117
|
policy = command_registry.mid_turn(line)
|
|
1095
1118
|
return start_anytime_command(line) if policy == :anytime
|
|
1096
1119
|
return command_queued(line) if policy == :queue
|
|
@@ -1108,12 +1131,37 @@ module Samagotchi
|
|
|
1108
1131
|
# Behind a command that waits for the turn's end, a line waits too.
|
|
1109
1132
|
return wait_for_turn_end(line) if @command_waits
|
|
1110
1133
|
|
|
1111
|
-
epoch = @engine.input_epoch
|
|
1112
1134
|
@pending_input_queue.push(line.strip)
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1135
|
+
@surface.commit(NEXT_STEP_NOTE)
|
|
1136
|
+
true
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
# /cut TEXT and /queue TEXT during a turn, which the REPL would
|
|
1140
|
+
# otherwise read as prompts (SessionCommands registers them for Tab and
|
|
1141
|
+
# help only). A cut asks the engine to cut the generation only thinking
|
|
1142
|
+
# too long; a queue waits for the turn's end (the REPL's own after-turn
|
|
1143
|
+
# list). Empty text gets the usage line. A line with images never steers
|
|
1144
|
+
# (as a plain one doesn't).
|
|
1145
|
+
# @return [Boolean] whether the turn took it
|
|
1146
|
+
def submit_steer_choice(choice, text)
|
|
1147
|
+
if text.empty?
|
|
1148
|
+
@surface.commit(STEER_CHOICE_USAGE)
|
|
1149
|
+
return true
|
|
1150
|
+
end
|
|
1151
|
+
return image_line_waits(text) if ImageInput.extract(text).any?
|
|
1152
|
+
|
|
1153
|
+
# A /queue waits for the turn's end; a /cut behind a command that
|
|
1154
|
+
# already waits there goes after it too.
|
|
1155
|
+
if choice == :queue || @command_waits
|
|
1156
|
+
@surface.commit(COMMAND_QUEUED)
|
|
1157
|
+
return wait_for_turn_end(text)
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
epoch = @engine.input_epoch
|
|
1161
|
+
@pending_input_queue.push(text)
|
|
1162
|
+
# With no session running the cut is :off; the text still waits for
|
|
1163
|
+
# the running turn's next step.
|
|
1164
|
+
@surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
|
|
1117
1165
|
true
|
|
1118
1166
|
end
|
|
1119
1167
|
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -10,7 +10,9 @@ module Samagotchi
|
|
|
10
10
|
# low, medium, high the effort a chat host is asked for
|
|
11
11
|
# default chi sends nothing: the provider's or template's own default
|
|
12
12
|
#
|
|
13
|
-
# Order (#resolve):
|
|
13
|
+
# Order (#resolve): the session's own level (/thinking, chi --thinking at
|
|
14
|
+
# start, chi send --new --thinking, the web), then the process default
|
|
15
|
+
# (chi web --thinking / SAMAGOTCHI_THINKING_LEVEL), then
|
|
14
16
|
# models.<key>.thinking, hosts.<name>.thinking, thinking.level in
|
|
15
17
|
# config.yml, then default.
|
|
16
18
|
module Thinking
|
|
@@ -33,8 +35,39 @@ module Samagotchi
|
|
|
33
35
|
# anything there (low/medium/high have no native knob).
|
|
34
36
|
Native = Data.define(:system_token, :prefill, :honoured)
|
|
35
37
|
|
|
38
|
+
# The source a session's own level reads as.
|
|
39
|
+
SESSION_SOURCE = "session"
|
|
40
|
+
|
|
41
|
+
# A level and where it came from (#explain): +source+ is "session",
|
|
42
|
+
# "--thinking", "SAMAGOTCHI_THINKING_LEVEL", "models: qwen",
|
|
43
|
+
# "hosts.work" or "thinking.level", nil for the default; +own+ is the
|
|
44
|
+
# session's own level, nil without one. What /thinking, /model,
|
|
45
|
+
# /stats, the snapshots and the web read.
|
|
46
|
+
Explained = Data.define(:level, :source, :own) do
|
|
47
|
+
# "low (session)", "default" with no source.
|
|
48
|
+
def label = source ? "#{level} (#{source})" : level.to_s
|
|
49
|
+
|
|
50
|
+
# The plain form the snapshots, the worker's command_ran event and
|
|
51
|
+
# the web read.
|
|
52
|
+
# @return [Hash] symbol keys
|
|
53
|
+
def summary = { level: level.to_s, source: source, own: own&.to_s }
|
|
54
|
+
end
|
|
55
|
+
|
|
36
56
|
module_function
|
|
37
57
|
|
|
58
|
+
# A session's own level from its file or a command word: :off, :low,
|
|
59
|
+
# :medium or :high; nil for anything else, default included (unset:
|
|
60
|
+
# the session follows the process default and the model). Never warns:
|
|
61
|
+
# a session file keeps a value it can't read as written.
|
|
62
|
+
# @return [Symbol, nil]
|
|
63
|
+
def session_level(value)
|
|
64
|
+
return value if LEVELS.include?(value) && value != DEFAULT
|
|
65
|
+
return nil unless value.is_a?(String)
|
|
66
|
+
|
|
67
|
+
level = value.strip.downcase.to_sym
|
|
68
|
+
LEVELS.include?(level) && level != DEFAULT ? level : nil
|
|
69
|
+
end
|
|
70
|
+
|
|
38
71
|
# A configured value as a level; nil when unset or not a level (that
|
|
39
72
|
# warns once, naming +where+). YAML reads an unquoted `off` as false,
|
|
40
73
|
# which is :off; true (`on`) isn't a level.
|
|
@@ -51,13 +84,17 @@ module Samagotchi
|
|
|
51
84
|
nil
|
|
52
85
|
end
|
|
53
86
|
|
|
54
|
-
# The level for +target+ and where it came from ("
|
|
55
|
-
# "models: qwen", "hosts.work", "thinking.level"; nil for
|
|
87
|
+
# The level for +target+ and where it came from ("session",
|
|
88
|
+
# "--thinking", "models: qwen", "hosts.work", "thinking.level"; nil for
|
|
89
|
+
# the default).
|
|
56
90
|
# @param target [HostRegistry::ModelTarget]
|
|
57
91
|
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
58
|
-
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
92
|
+
# @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
|
|
93
|
+
# @param session [Symbol, nil] the session's own level (Session#thinking)
|
|
59
94
|
# @return [Array(Symbol, String|nil)]
|
|
60
|
-
def resolve(target, names:, models: nil)
|
|
95
|
+
def resolve(target, names:, models: nil, session: nil)
|
|
96
|
+
return [session, SESSION_SOURCE] if session
|
|
97
|
+
|
|
61
98
|
global, origin = global_level
|
|
62
99
|
return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
|
|
63
100
|
|
|
@@ -66,8 +103,8 @@ module Samagotchi
|
|
|
66
103
|
rescue StandardError
|
|
67
104
|
{}
|
|
68
105
|
end
|
|
69
|
-
|
|
70
|
-
return [value, "models: #{key}"] if
|
|
106
|
+
setting = ConfigFile.model_setting(names, :thinking, models: models)
|
|
107
|
+
return [setting.value, "models: #{setting.key}"] if setting
|
|
71
108
|
|
|
72
109
|
host = target.entry.thinking
|
|
73
110
|
return [host, "hosts.#{target.entry.name}"] if host
|
|
@@ -76,6 +113,13 @@ module Samagotchi
|
|
|
76
113
|
[DEFAULT, nil]
|
|
77
114
|
end
|
|
78
115
|
|
|
116
|
+
# #resolve, as an Explained with the session's own level beside it.
|
|
117
|
+
# @return [Explained]
|
|
118
|
+
def explain(target, names:, models: nil, session: nil)
|
|
119
|
+
level, source = resolve(target, names: names, models: models, session: session)
|
|
120
|
+
Explained.new(level: level, source: source, own: session)
|
|
121
|
+
end
|
|
122
|
+
|
|
79
123
|
# thinking.level as a level and its origin (:cli, :env, :file), or nil.
|
|
80
124
|
def global_level
|
|
81
125
|
raw, origin = Config.get_with_origin("thinking.level")
|
|
@@ -88,6 +132,22 @@ module Samagotchi
|
|
|
88
132
|
nil
|
|
89
133
|
end
|
|
90
134
|
|
|
135
|
+
# What a level change costs the prompt cache on +target+ (/thinking's
|
|
136
|
+
# note; a confirm step may ask before the costly ones):
|
|
137
|
+
# :full the native Gemma 4 prompt starts with <|think|>, so the
|
|
138
|
+
# whole prompt is read again;
|
|
139
|
+
# :provider a chat host gets other request fields: a local llama.cpp
|
|
140
|
+
# or Splash server keeps its cache, a hosted API may not;
|
|
141
|
+
# :tail native Qwen and the rest: only the tail changes.
|
|
142
|
+
# @param target [HostRegistry::ModelTarget]
|
|
143
|
+
# @param profile [String, nil] the native prompt profile's name
|
|
144
|
+
# @return [Symbol]
|
|
145
|
+
def cache_cost(target, profile: nil)
|
|
146
|
+
return :provider if target.entry&.chat?
|
|
147
|
+
|
|
148
|
+
profile == "gemma4" ? :full : :tail
|
|
149
|
+
end
|
|
150
|
+
|
|
91
151
|
# Request fields for an OpenAI-style chat host. off sends both switches
|
|
92
152
|
# (the template's enable_thinking and the OpenAI-style reasoning_effort
|
|
93
153
|
# "none"): llama.cpp, Splash and OpenRouter each honour one of them.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "thinking"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# /thinking, the session's own thinking level (Session#thinking): with no
|
|
7
|
+
# argument, the level the next turn runs at and where it came from;
|
|
8
|
+
# `/thinking off|low|medium|high` sets the session's own, `/thinking
|
|
9
|
+
# default` unsets it (the session follows the process default, the
|
|
10
|
+
# model, its host, then thinking.level). A change takes effect at the
|
|
11
|
+
# next turn's start (the REPL and the worker run it between turns), and
|
|
12
|
+
# the reply says what it costs the prompt cache (Thinking.cache_cost).
|
|
13
|
+
class ThinkingCommand
|
|
14
|
+
NAME = "/thinking"
|
|
15
|
+
SETTABLE = (Thinking::LEVELS - [Thinking::DEFAULT]).freeze
|
|
16
|
+
USAGE = "usage: #{NAME} [#{Thinking::LEVELS.join("|")}]".freeze
|
|
17
|
+
|
|
18
|
+
COST_NOTES = {
|
|
19
|
+
full: "On this model the whole prompt is read again (Gemma's <|think|> starts it).",
|
|
20
|
+
provider: "The request's thinking fields change: a local llama.cpp or Splash server keeps its cache; " \
|
|
21
|
+
"hosted APIs (OpenRouter → Claude, OpenAI) may restart theirs.",
|
|
22
|
+
tail: "Only the prompt's tail changes: the cache keeps the rest."
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# @param engine [Engine] #thinking_explained, #thinking_override(=),
|
|
26
|
+
# #session, #thinking_cache_cost
|
|
27
|
+
# @param save [#call] saves the session after a change
|
|
28
|
+
def initialize(engine:, save:)
|
|
29
|
+
@engine = engine
|
|
30
|
+
@save = save
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param args [String] what follows /thinking
|
|
34
|
+
# @return [Array(String, Boolean)] the reply and whether the level changed
|
|
35
|
+
# @raise [ArgumentError] a word that isn't a level (with USAGE)
|
|
36
|
+
def run(args)
|
|
37
|
+
word = args.to_s.strip.downcase
|
|
38
|
+
return [listing(@engine.thinking_explained), false] if word.empty?
|
|
39
|
+
|
|
40
|
+
level = word.to_sym
|
|
41
|
+
raise ArgumentError, "unknown thinking level #{word}\n#{USAGE}" unless Thinking::LEVELS.include?(level)
|
|
42
|
+
|
|
43
|
+
session = @engine.session or raise ArgumentError, "no session yet"
|
|
44
|
+
before = @engine.thinking_explained
|
|
45
|
+
@engine.thinking_override = SETTABLE.include?(level) ? level : nil
|
|
46
|
+
@save.call(session)
|
|
47
|
+
after = @engine.thinking_explained
|
|
48
|
+
[[listing(after), *notes(before, after)].join("\n"), true]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The level, where it came from, and what the session sets itself.
|
|
52
|
+
def listing(explained)
|
|
53
|
+
return "thinking: can't be resolved for this model" unless explained
|
|
54
|
+
|
|
55
|
+
lines = ["thinking: #{explained.label}"]
|
|
56
|
+
unless explained.own
|
|
57
|
+
lines << " this session's own: none (follows chi web --thinking / SAMAGOTCHI_THINKING_LEVEL, the model, " \
|
|
58
|
+
"its host, then thinking.level)"
|
|
59
|
+
end
|
|
60
|
+
lines.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def notes(before, after)
|
|
66
|
+
return [] unless before && after
|
|
67
|
+
return ["(no change to what the next turn runs under)"] if before.level == after.level
|
|
68
|
+
|
|
69
|
+
["From the next turn's start (a running turn keeps its own).", COST_NOTES[@engine.thinking_cache_cost]].compact
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -47,6 +47,9 @@ module Samagotchi
|
|
|
47
47
|
}
|
|
48
48
|
title = tool_title(tool_name, call, cwd: cwd)
|
|
49
49
|
event[:title] = title if title
|
|
50
|
+
# The name the model used when an alias ran a built-in (called_as: "bash"
|
|
51
|
+
# for an execute), for a UI that shows it.
|
|
52
|
+
event[:called_as] = call[:called_as] if call.is_a?(Hash) && call[:called_as]
|
|
50
53
|
# The model's own words for a command, which the TUI's tool line
|
|
51
54
|
# shows in place of the cut params.
|
|
52
55
|
description = command_description(tool_name, call)
|
|
@@ -57,6 +57,8 @@ module Samagotchi
|
|
|
57
57
|
# The worker runs in its session's working directory.
|
|
58
58
|
title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
|
|
59
59
|
started[:title] = title if title
|
|
60
|
+
# The name the model called it by when that was an alias's (bash).
|
|
61
|
+
started[:called_as] = call[:called_as] if call[:called_as]
|
|
60
62
|
# The full command for a richer UI (params stays cut at 80).
|
|
61
63
|
view = ToolView.for(call[:name], call)&.to_h
|
|
62
64
|
started[:view] = view if view
|
|
@@ -56,6 +56,13 @@ module Samagotchi
|
|
|
56
56
|
"forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] }
|
|
57
57
|
}.freeze
|
|
58
58
|
|
|
59
|
+
# A name the model uses for a built-in, mapped to that built-in (a model
|
|
60
|
+
# that keeps calling `bash` with execute's arguments; before this such a
|
|
61
|
+
# call died as an unknown tool). It only takes effect when the
|
|
62
|
+
# arguments really are the target's shape; the call then carries
|
|
63
|
+
# called_as: with the model's own spelling.
|
|
64
|
+
ALIASES = { "bash" => "execute" }.freeze
|
|
65
|
+
|
|
59
66
|
# One built-in's mapping, resolved from its schema and OVERRIDES.
|
|
60
67
|
Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
|
|
61
68
|
:options) do
|
|
@@ -109,6 +116,14 @@ module Samagotchi
|
|
|
109
116
|
def build(name, args, raw: nil)
|
|
110
117
|
name = name.to_s
|
|
111
118
|
args = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
|
|
119
|
+
# An alias runs its target (bash → execute); the row is the target's,
|
|
120
|
+
# and the call keeps the model's own spelling on called_as:.
|
|
121
|
+
# Not the target's shape (a String main argument, only its keys) and
|
|
122
|
+
# the alias doesn't take: the call passes through as an unknown tool.
|
|
123
|
+
if (target = ALIASES[name.downcase])
|
|
124
|
+
return aliasable?(row(target), args) ? aliased(name, target, args, raw) : passthrough(name, args, raw)
|
|
125
|
+
end
|
|
126
|
+
|
|
112
127
|
row = row(name)
|
|
113
128
|
return passthrough(name, args, raw) unless row
|
|
114
129
|
|
|
@@ -120,6 +135,24 @@ module Samagotchi
|
|
|
120
135
|
call
|
|
121
136
|
end
|
|
122
137
|
|
|
138
|
+
# An execute-shaped call the model spelled +name+: built as its +target+,
|
|
139
|
+
# tagged with the model's spelling for the activity event and the log.
|
|
140
|
+
def aliased(name, target, args, raw)
|
|
141
|
+
call = build(target, args, raw: raw)
|
|
142
|
+
call[:called_as] = name
|
|
143
|
+
call
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The alias gate: a String main argument, and every key one the target's
|
|
147
|
+
# schema knows (its aliases included). More, or differently shaped, the
|
|
148
|
+
# call is the model's own and passes through as an unknown tool.
|
|
149
|
+
def aliasable?(row, args)
|
|
150
|
+
return false unless row
|
|
151
|
+
|
|
152
|
+
row.content_keys.map { |key| value(row, args, key) }.any?(String) &&
|
|
153
|
+
(args.keys - (row.content_keys + [row.path_key].compact + row.fields + row.aliases.keys)).empty?
|
|
154
|
+
end
|
|
155
|
+
|
|
123
156
|
def content_value(row, args)
|
|
124
157
|
found = row.content_keys.lazy.map { |key| value(row, args, key) }.find { |v| !v.nil? }
|
|
125
158
|
found.nil? ? "" : found.to_s
|
|
@@ -119,15 +119,19 @@ module Samagotchi
|
|
|
119
119
|
# wrapper. The session's id, "chi" without one. And the model the
|
|
120
120
|
# session runs on (SAMAGOTCHI_SESSION_MODEL, read live, so right after
|
|
121
121
|
# /model), which chi self reports; unset when unknown, so a value
|
|
122
|
-
# inherited from an outer session never passes for this one.
|
|
122
|
+
# inherited from an outer session never passes for this one. So too
|
|
123
|
+
# the session's own thinking level (SAMAGOTCHI_SESSION_THINKING, unset
|
|
124
|
+
# without one), which chi self puts first. A
|
|
123
125
|
# worker's `chi --model X` default (ModelProfile::MODEL_FROM_CLI_ENV)
|
|
124
126
|
# is unset: it was that run's, not the default of a chi run here.
|
|
125
127
|
def parent_env(kctx, env: ENV)
|
|
126
128
|
peers = kctx.peers
|
|
127
129
|
id = peers.respond_to?(:session_id) ? peers.session_id.to_s : ""
|
|
128
130
|
model = peers.respond_to?(:model_ref) ? peers.model_ref.to_s : ""
|
|
131
|
+
thinking = peers.respond_to?(:thinking) ? peers.thinking&.to_s : nil
|
|
129
132
|
{ Guardrails::ParentApprovals::PARENT_SESSION_ENV => id.empty? ? "chi" : id,
|
|
130
|
-
SESSION_MODEL_ENV => model.empty? ? nil : model
|
|
133
|
+
SESSION_MODEL_ENV => model.empty? ? nil : model,
|
|
134
|
+
SESSION_THINKING_ENV => thinking }.merge(cli_model_unset(env))
|
|
131
135
|
end
|
|
132
136
|
|
|
133
137
|
# The unsets that keep a `chi --model X` worker's default from passing
|
|
@@ -139,6 +143,7 @@ module Samagotchi
|
|
|
139
143
|
end
|
|
140
144
|
|
|
141
145
|
SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
|
|
146
|
+
SESSION_THINKING_ENV = "SAMAGOTCHI_SESSION_THINKING"
|
|
142
147
|
|
|
143
148
|
# task_create's parent_env, less the unsets of a variable the model
|
|
144
149
|
# set in its env (a SAMAGOTCHI_DEFAULT_MODEL it asked for stays).
|
|
@@ -8,10 +8,11 @@ module Samagotchi
|
|
|
8
8
|
# canceled (a waiting tool returns on it), and the question flow a
|
|
9
9
|
# delegate's approval is relayed through (relay: nil when this session
|
|
10
10
|
# can't host one), and the model the session runs on (model_ref: the
|
|
11
|
-
# resolved ref, live after /model)
|
|
11
|
+
# resolved ref, live after /model), and the session's own thinking level
|
|
12
|
+
# (thinking: nil without one). Engine hands KernelLoop one that follows its current
|
|
12
13
|
# session (Engine::PeerView); this plain one is for specs and a kernel
|
|
13
14
|
# without an Engine.
|
|
14
|
-
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, keyword_init: true) do
|
|
15
|
+
Peers = Struct.new(:session_id, :cwd, :state_dir, :cancelled, :relay, :model_ref, :thinking, keyword_init: true) do
|
|
15
16
|
# @return [Boolean] the turn was canceled (:cancelled is a proc or a value)
|
|
16
17
|
def cancelled?
|
|
17
18
|
cancelled.respond_to?(:call) ? !!cancelled.call : !!cancelled
|
|
@@ -22,7 +22,7 @@ module Samagotchi
|
|
|
22
22
|
OUTPUT_TAIL_READ_BYTES = 16 * 1024
|
|
23
23
|
SANITIZED_ENV_KEYS = %w[RUBYOPT RUBYLIB BUNDLE_GEMFILE BUNDLE_BIN_PATH BUNDLER_VERSION].freeze
|
|
24
24
|
# Set by chi (Builtins.parent_env); the model's env can't change it.
|
|
25
|
-
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
25
|
+
MARKER_ENV_KEYS = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
|
|
26
26
|
# task_wait and task_get add it to a task the user stopped (the web's
|
|
27
27
|
# stop-task button), so the model doesn't rerun the job.
|
|
28
28
|
# A record whose pid isn't a process group chi started (missing, 0, 1,
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Samagotchi
|
|
|
24
24
|
# @param target [HostRegistry::ModelTarget]
|
|
25
25
|
# @param profile [ModelProfile, nil] the model's prompt profile (native)
|
|
26
26
|
# @param adapter [LLM::OpenAIChat, nil] the chat host's adapter
|
|
27
|
-
# @param models [Hash, nil] ConfigFile.model_settings (specs)
|
|
27
|
+
# @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
|
|
28
28
|
# @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
|
|
29
29
|
# @return [Answer]
|
|
30
30
|
def self.for(target, names:, profile: nil, adapter: nil, models: nil)
|
|
@@ -51,8 +51,9 @@ module Samagotchi
|
|
|
51
51
|
rescue StandardError
|
|
52
52
|
{}
|
|
53
53
|
end
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
if (setting = ConfigFile.model_setting(names, :vision, models: models))
|
|
55
|
+
return Answer.new(value: setting.value, reason: setting.value ? nil : "models: #{setting.key} sets vision: false")
|
|
56
|
+
end
|
|
56
57
|
return nil if entry.vision.nil?
|
|
57
58
|
|
|
58
59
|
Answer.new(value: entry.vision, reason: entry.vision ? nil : "hosts.#{entry.name} sets vision: false")
|