samagotchi 0.51.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 +46 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +23 -8
- data/docs/configuration.md +59 -19
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -1
- 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.rb +10 -13
- 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 +23 -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 +5 -10
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
- 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 +37 -19
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +36 -7
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +8 -7
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/steer_cut.rb +9 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
- data/lib/samagotchi/terminal_ui.rb +8 -3
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -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 +54 -6
- data/lib/samagotchi/web/public/app.js +133 -20
- 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 +8 -5
- 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/thinking_chip.js +169 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +23 -0
- 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 +43 -42
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- metadata +11 -1
|
@@ -183,20 +183,29 @@ module Samagotchi
|
|
|
183
183
|
Dir.glob(File.join(input_dir, "*.json"))
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
191
|
-
|
|
186
|
+
# A claimed input file as read: its +prompt+, +origin+ ({client_id:,
|
|
187
|
+
# enqueued_id:}, nil when it names no sender), whether its turn runs
|
|
188
|
+
# with the raised iteration limit (+no_interrupt+, --no-interrupt), its
|
|
189
|
+
# +images+ ({file:, name:} refs), and its +delivery+ ("queue" or nil; a
|
|
190
|
+
# file without the key is one an older chi wrote, so nil).
|
|
191
|
+
Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery) do
|
|
192
|
+
def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil) = super
|
|
193
|
+
end
|
|
194
|
+
# A file that doesn't parse, or isn't a JSON object: no prompt.
|
|
195
|
+
UNREADABLE_INPUT = Input.new(prompt: nil)
|
|
196
|
+
|
|
197
|
+
# @return [Input] a claimed input file's contents; UNREADABLE_INPUT for
|
|
198
|
+
# one that isn't a JSON object
|
|
192
199
|
def self.read_input(claimed_file)
|
|
193
200
|
data = JSON.parse(File.read(claimed_file).to_s)
|
|
201
|
+
return UNREADABLE_INPUT unless data.is_a?(Hash)
|
|
202
|
+
|
|
194
203
|
origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
|
|
195
204
|
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
196
|
-
|
|
197
|
-
|
|
205
|
+
Input.new(prompt: data["prompt"].to_s, origin: origin.empty? ? nil : origin, no_interrupt: data["no_interrupt"] == true,
|
|
206
|
+
images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil)
|
|
198
207
|
rescue JSON::ParserError
|
|
199
|
-
|
|
208
|
+
UNREADABLE_INPUT
|
|
200
209
|
end
|
|
201
210
|
|
|
202
211
|
# Whether an unclaimed input file carries images (a mid-turn drain
|
|
@@ -14,6 +14,7 @@ require_relative "session"
|
|
|
14
14
|
require_relative "session_inbox"
|
|
15
15
|
require_relative "session_metrics"
|
|
16
16
|
require_relative "llm_context_strategy"
|
|
17
|
+
require_relative "session_setup"
|
|
17
18
|
require_relative "host_registry"
|
|
18
19
|
require_relative "utf8_default"
|
|
19
20
|
require_relative "turn_note"
|
|
@@ -163,8 +164,8 @@ module Samagotchi
|
|
|
163
164
|
# @param images_from [String, nil] the session dir the seed's images are in
|
|
164
165
|
# @param title [String, nil] what the lists show before the first turn
|
|
165
166
|
# (the prompt's preview by default, else the seed's first user message)
|
|
166
|
-
# @param
|
|
167
|
-
#
|
|
167
|
+
# @param setup [SessionSetup] the session's own settings (chi
|
|
168
|
+
# --llm-context; a fork and a continue copy their session's)
|
|
168
169
|
# @param continues [String, nil] the session this one continues (a
|
|
169
170
|
# session field; #continue_session)
|
|
170
171
|
# @param note [String, nil] a context note the session starts with,
|
|
@@ -176,7 +177,7 @@ module Samagotchi
|
|
|
176
177
|
# and #model_warning: an id its host's saved list doesn't have
|
|
177
178
|
def self.spawn_session(prompt:, mode: "assist", working_directory: nil, model_name: nil, state_dir: nil,
|
|
178
179
|
memories: [], muted_memories: [], parent_id: nil, messages: [], images_from: nil,
|
|
179
|
-
title: nil, delegate: false,
|
|
180
|
+
title: nil, delegate: false, setup: SessionSetup.new, continues: nil, note: nil,
|
|
180
181
|
note_source: "chi", before_spawn: nil)
|
|
181
182
|
sd = state_dir || Session.default_state_dir
|
|
182
183
|
# The resolved ref is stored (a resumed session keeps its model when an
|
|
@@ -198,7 +199,7 @@ module Samagotchi
|
|
|
198
199
|
parent_id: parent_id,
|
|
199
200
|
messages: messages,
|
|
200
201
|
delegate: delegate,
|
|
201
|
-
|
|
202
|
+
setup: setup,
|
|
202
203
|
continues: continues
|
|
203
204
|
)
|
|
204
205
|
session.model_warning = model_warning
|
|
@@ -232,7 +233,7 @@ module Samagotchi
|
|
|
232
233
|
|
|
233
234
|
# Start the next link of a chain: a new session that continues
|
|
234
235
|
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
235
|
-
# it was typed as, so an alias holds) and with its own
|
|
236
|
+
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
236
237
|
# starting with a context note from chi: the link and the previous
|
|
237
238
|
# link's recap (SessionChain.note_text). The previous link is archived
|
|
238
239
|
# first, with its finished delegates (archive_session's rules: a turn
|
|
@@ -363,7 +364,7 @@ module Samagotchi
|
|
|
363
364
|
ChildMove.apply(move, from: previous.id, to: link.id, state_dir: state_dir)
|
|
364
365
|
end
|
|
365
366
|
link = spawn_session(prompt: start, title: title, working_directory: previous.working_directory,
|
|
366
|
-
model_name: previous.model_typed || previous.model_name,
|
|
367
|
+
model_name: previous.model_typed || previous.model_name, setup: SessionSetup.of(previous),
|
|
367
368
|
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
368
369
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn)
|
|
369
370
|
link.moved_children = move
|
|
@@ -515,7 +516,7 @@ module Samagotchi
|
|
|
515
516
|
|
|
516
517
|
GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
|
|
517
518
|
# Tools::Builtins.parent_env's (Tools::TaskRuntime::MARKER_ENV_KEYS).
|
|
518
|
-
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL].freeze
|
|
519
|
+
SESSION_MARKER_ENV = %w[SAMAGOTCHI_PARENT_SESSION SAMAGOTCHI_SESSION_MODEL SAMAGOTCHI_SESSION_THINKING].freeze
|
|
519
520
|
|
|
520
521
|
# List all sessions, reading status from persisted session.json files.
|
|
521
522
|
# +project_root+: only that project's sessions (nil: every session).
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "llm_context_override"
|
|
4
|
+
require_relative "thinking"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
# A session's own settings that come before its model's, carried as one
|
|
8
|
+
# value on the paths that spawn a session from a whole setup
|
|
9
|
+
# (SessionManager.spawn_session, a continue, a plugin's fork, the web's
|
|
10
|
+
# create, chi send --new): +llm_context+ is an LLMContextOverride or nil,
|
|
11
|
+
# +thinking+ a level Symbol (Thinking.session_level) or nil.
|
|
12
|
+
#
|
|
13
|
+
# It doesn't save itself: the session keeps each setting in its own field
|
|
14
|
+
# and file key, and resolves it on its own. A delegate child starts
|
|
15
|
+
# without one; a continue and a fork copy their session's (.of).
|
|
16
|
+
SessionSetup = Data.define(:llm_context, :thinking) do
|
|
17
|
+
def initialize(llm_context: nil, thinking: nil) = super
|
|
18
|
+
|
|
19
|
+
def empty? = llm_context.nil? && thinking.nil?
|
|
20
|
+
|
|
21
|
+
# A session's own setup, for a session that starts from it.
|
|
22
|
+
# @param session [Session, nil]
|
|
23
|
+
# @return [SessionSetup]
|
|
24
|
+
def self.of(session)
|
|
25
|
+
return new if session.nil?
|
|
26
|
+
|
|
27
|
+
new(llm_context: session.llm_context, thinking: session.thinking)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/samagotchi/steer_cut.rb
CHANGED
|
@@ -22,6 +22,13 @@ module Samagotchi
|
|
|
22
22
|
{ thinking: event[:thinking].to_s, text: text.to_s }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# steer.cut_after: how long a generation streams only thinking before a
|
|
26
|
+
# message may cut it, in whole seconds (0 or less: cutting is off).
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
def self.cut_after
|
|
29
|
+
Config.get("steer.cut_after").to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
25
32
|
# +clock+: monotonic seconds; +controller+: the running turn's cancel
|
|
26
33
|
# controller (nil between turns), read only when a cut is due.
|
|
27
34
|
def initialize(clock:, controller:)
|
|
@@ -51,7 +58,7 @@ module Samagotchi
|
|
|
51
58
|
def cut_for_steer(source, epoch: nil)
|
|
52
59
|
return :off unless Steer.cuts?(source)
|
|
53
60
|
|
|
54
|
-
after =
|
|
61
|
+
after = self.class.cut_after
|
|
55
62
|
return :off unless after.positive?
|
|
56
63
|
|
|
57
64
|
ctrl = @controller.call
|
|
@@ -104,7 +111,7 @@ module Samagotchi
|
|
|
104
111
|
def recheck
|
|
105
112
|
return unless @waiting.waiting?
|
|
106
113
|
|
|
107
|
-
after =
|
|
114
|
+
after = self.class.cut_after
|
|
108
115
|
return unless after.positive? && @phase.cuttable?(after)
|
|
109
116
|
|
|
110
117
|
source = @waiting.take
|
|
@@ -4,6 +4,7 @@ require_relative "../session"
|
|
|
4
4
|
require_relative "../config"
|
|
5
5
|
require_relative "../model_profile"
|
|
6
6
|
require_relative "../session_manager"
|
|
7
|
+
require_relative "../thinking_command"
|
|
7
8
|
require_relative "../bridge_client"
|
|
8
9
|
require_relative "../parent_report"
|
|
9
10
|
require_relative "../cli/exit"
|
|
@@ -40,16 +41,20 @@ module Samagotchi
|
|
|
40
41
|
# (LLMContextOverride.update): a new session starts with them; a
|
|
41
42
|
# resumed or attached one's worker gets a /llm-context, after the
|
|
42
43
|
# /model
|
|
44
|
+
# @param thinking [String, nil] --thinking as typed: a new session
|
|
45
|
+
# starts with it as its own level; a resumed or attached one's
|
|
46
|
+
# worker gets a /thinking, after those
|
|
43
47
|
# @return [Symbol] :detached, :closed when the worker went away,
|
|
44
48
|
# :failed when the --model switch didn't go through, or (input from a
|
|
45
49
|
# pipe) :turn_failed / :empty_answer / :unanswered (AttachedLoop#run)
|
|
46
50
|
def run(attach: nil, shared: false, resume: nil, prompt: nil, model: nil, no_interrupt: false, default_input: true,
|
|
47
|
-
memories: [], muted_memories: [], llm_context: {})
|
|
51
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil)
|
|
48
52
|
client = connect(attach: attach, shared: shared, resume: resume, model: model,
|
|
49
|
-
memories: memories, muted_memories: muted_memories, llm_context: llm_context)
|
|
53
|
+
memories: memories, muted_memories: muted_memories, llm_context: llm_context, thinking: thinking)
|
|
50
54
|
commands = []
|
|
51
55
|
commands << "/model #{model}" if model && (attach || resume)
|
|
52
56
|
commands << LLMContextOverride.command_line(llm_context) if (attach || resume) && !llm_context.empty?
|
|
57
|
+
commands << "#{ThinkingCommand::NAME} #{thinking}" if (attach || resume) && thinking
|
|
53
58
|
first_command = commands.size > 1 ? commands : commands.first
|
|
54
59
|
surface = open_surface
|
|
55
60
|
attached = AttachedLoop.new(client: client, screen: surface, client_id: "tui:#{Process.pid}", first_prompt: prompt,
|
|
@@ -111,7 +116,7 @@ module Samagotchi
|
|
|
111
116
|
# @return [BridgeClient] a client for the live Bridge of the session
|
|
112
117
|
# @raise [Error]
|
|
113
118
|
def connect(attach: nil, shared: false, resume: nil, model: nil, state_dir: nil, wait: BRIDGE_WAIT,
|
|
114
|
-
memories: [], muted_memories: [], llm_context: {}, err: $stderr)
|
|
119
|
+
memories: [], muted_memories: [], llm_context: {}, thinking: nil, err: $stderr)
|
|
115
120
|
sd = state_dir || Session.default_state_dir
|
|
116
121
|
warn_memory_flags_ignored(attach || resume, memories, muted_memories, err) if attach || resume
|
|
117
122
|
if attach
|
|
@@ -127,7 +132,8 @@ module Samagotchi
|
|
|
127
132
|
else
|
|
128
133
|
SessionManager.spawn_session(prompt: nil, model_name: model && model_ref(model), state_dir: sd,
|
|
129
134
|
memories: memories, muted_memories: muted_memories,
|
|
130
|
-
llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context)
|
|
135
|
+
setup: SessionSetup.new(llm_context: llm_context.empty? ? nil : LLMContextOverride.update(nil, llm_context),
|
|
136
|
+
thinking: Thinking.session_level(thinking)))
|
|
131
137
|
end
|
|
132
138
|
client = BridgeClient.wait_for(session.id, session_dir: Session.session_dir(session.id, state_dir: sd), timeout: wait)
|
|
133
139
|
client || raise(Error, "the worker for session #{session.id} did not start its Bridge in time")
|
|
@@ -430,7 +430,7 @@ module Samagotchi
|
|
|
430
430
|
# @return [nil]
|
|
431
431
|
def submit_delivery(choice, text)
|
|
432
432
|
return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
|
|
433
|
-
return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue
|
|
433
|
+
return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue && @running
|
|
434
434
|
|
|
435
435
|
# To an idle session it is an ordinary prompt: no delivery to send.
|
|
436
436
|
send_prompt(text, delivery: @running ? Delivery::CUT : nil)
|
|
@@ -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
|
|
|
@@ -99,7 +100,8 @@ module Samagotchi
|
|
|
99
100
|
# line goes in at the running turn's next step boundary (nothing cuts
|
|
100
101
|
# unless the message asked for it), /cut's cutting now or once the
|
|
101
102
|
# thinking passes steer.cut_after, and /queue's waiting for the end.
|
|
102
|
-
# Both TUIs print the same words
|
|
103
|
+
# Both TUIs and chi send --cut print the same words
|
|
104
|
+
# (SteerCut#cut_for_steer's answer).
|
|
103
105
|
NEXT_STEP_NOTE = "(goes in at the next step)"
|
|
104
106
|
CUT_NOW_NOTE = "(cut in now)"
|
|
105
107
|
CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
|
|
@@ -115,14 +117,17 @@ module Samagotchi
|
|
|
115
117
|
|
|
116
118
|
# @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
|
|
117
119
|
# @return [String] the dim line to show for a /cut
|
|
118
|
-
def steer_cut_note(outcome)
|
|
120
|
+
def self.steer_cut_note(outcome)
|
|
119
121
|
case outcome
|
|
120
122
|
when :now then CUT_NOW_NOTE
|
|
121
|
-
when :waits then format(CUT_WAITS_NOTE,
|
|
123
|
+
when :waits then format(CUT_WAITS_NOTE, SteerCut.cut_after)
|
|
122
124
|
else CUT_OFF_NOTE
|
|
123
125
|
end
|
|
124
126
|
end
|
|
125
127
|
|
|
128
|
+
# The same words, for a UI that includes the module.
|
|
129
|
+
def steer_cut_note(outcome) = Formatting.steer_cut_note(outcome)
|
|
130
|
+
|
|
126
131
|
# The choice a mid-turn line carries, as [:cut, "TEXT"] or
|
|
127
132
|
# [:queue, "TEXT"]; nil for any other line. The text is "" when the
|
|
128
133
|
# line named the choice without one.
|
|
@@ -591,6 +596,8 @@ module Samagotchi
|
|
|
591
596
|
lines << "model notes: #{notes}" unless notes.empty?
|
|
592
597
|
llm_context = llm_context_stats_text(snapshot[:llm_context])
|
|
593
598
|
lines << "llm context: #{llm_context}" if llm_context
|
|
599
|
+
thinking = thinking_stats_text(snapshot[:thinking])
|
|
600
|
+
lines << "thinking: #{thinking}" if thinking
|
|
594
601
|
if snapshot[:served_model]
|
|
595
602
|
asked = snapshot[:served_model_for]
|
|
596
603
|
differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
|
|
@@ -630,6 +637,17 @@ module Samagotchi
|
|
|
630
637
|
"budget #{budget} (#{get.call(:budget_where)})"
|
|
631
638
|
end
|
|
632
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
|
+
|
|
633
651
|
# ", cached 4864 (93%), cache writes 312, re-prefilled 2100, reasoning
|
|
634
652
|
# 212" for the tokens line; the counts a server didn't report are left
|
|
635
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
|
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
|
|
@@ -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")
|