samagotchi 0.93.0 → 0.95.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 +60 -1
- data/README.md +8 -8
- data/bin/chi +6 -2
- data/docs/architecture.md +4 -5
- data/docs/cli.md +4 -4
- data/docs/configuration.md +49 -86
- data/docs/hooks.md +2 -2
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/context-telemetry.md +1 -1
- data/docs/internals/engine.md +7 -4
- data/docs/memory.md +26 -1
- data/docs/plugins.md +37 -0
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +16 -11
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -5
- data/lib/samagotchi/bootstrap_command.rb +1 -0
- data/lib/samagotchi/bundle_command/try/sandbox.rb +8 -2
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
- data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/client.rb +59 -196
- data/lib/samagotchi/config.rb +74 -15
- data/lib/samagotchi/engine.rb +7 -7
- data/lib/samagotchi/host_config.rb +20 -27
- data/lib/samagotchi/host_registry.rb +8 -8
- data/lib/samagotchi/kernel_loop.rb +46 -8
- data/lib/samagotchi/memory_bundle/profile.rb +22 -4
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
- data/lib/samagotchi/model_profile.rb +2 -3
- data/lib/samagotchi/self_report.rb +0 -2
- data/lib/samagotchi/system_prompt.rb +7 -21
- data/lib/samagotchi/terminal_ui/thinking_line.rb +2 -4
- data/lib/samagotchi/testing/sandbox.rb +26 -0
- data/lib/samagotchi/thinking.rb +35 -2
- data/lib/samagotchi/thinking_notices.rb +11 -4
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +0 -3
- data/lib/samagotchi/web/public/app.js +15 -82
- data/lib/samagotchi/web/public/dock_reserve.js +103 -0
- data/lib/samagotchi/web/public/index.html +3 -3
- data/lib/samagotchi/web/public/toast_place.js +1 -1
- metadata +5 -1
|
@@ -13,13 +13,13 @@ module Samagotchi
|
|
|
13
13
|
# one).
|
|
14
14
|
#
|
|
15
15
|
# name: the lowercased entry name; host, port, scheme: where it is (from
|
|
16
|
-
# url: when given); url: the configured url, nil when host/port;
|
|
17
|
-
#
|
|
16
|
+
# url: when given); url: the configured url, nil when host/port; api: a
|
|
17
|
+
# symbol, nil when unset (llama_cpp's raw-prompt loop); auth: the header the API key goes in
|
|
18
18
|
# (LLM::ApiKey::SCHEMES), nil when unset; dialect: an api: anthropic
|
|
19
19
|
# host's LLM::Dialect name, nil when unset; the rest as
|
|
20
20
|
# HostRegistry::HostEntry describes them, nil when unset; models:
|
|
21
21
|
# {downcased id => HostModel}.
|
|
22
|
-
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :
|
|
22
|
+
HostConfig = Data.define(:name, :host, :port, :scheme, :url, :api, :api_key_env, :auth, :dialect, :profile,
|
|
23
23
|
:first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
24
24
|
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
25
25
|
:models) do
|
|
@@ -79,10 +79,10 @@ module Samagotchi
|
|
|
79
79
|
ConfigFile.warn_once "Warning: #{where}: auth has no effect without api_key_env (no key is sent)"
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
|
|
83
|
-
location = parse_location(name, value, api:
|
|
84
|
-
fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api:
|
|
85
|
-
new(name: name.downcase, **location, **
|
|
82
|
+
kind = parse_api(name, value.call(:api), value.call(:transport)) or return nil
|
|
83
|
+
location = parse_location(name, value, api: kind[:api]) or return nil
|
|
84
|
+
fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api: kind[:api])
|
|
85
|
+
new(name: name.downcase, **location, **kind, **fields)
|
|
86
86
|
rescue StandardError => e
|
|
87
87
|
# One entry that trips a reader is dropped on its own, and said so;
|
|
88
88
|
# the other hosts stay (the worker's copy too).
|
|
@@ -143,29 +143,22 @@ module Samagotchi
|
|
|
143
143
|
end
|
|
144
144
|
private_class_method :parse_location
|
|
145
145
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
return invalid(name, "
|
|
146
|
+
# { api: } (a symbol, nil when not given), or nil (warned) when the api
|
|
147
|
+
# is unknown. A removed api (RemovedApi) in api: or in the old
|
|
148
|
+
# transport: key leaves the entry out with its line; any other
|
|
149
|
+
# transport: is only warned about at start
|
|
150
|
+
# (ConfigFile.removed_transport_problem).
|
|
151
|
+
def self.parse_api(name, api, transport)
|
|
152
|
+
%w[api transport].zip([api, transport]).each do |key, written|
|
|
153
|
+
return invalid(name, ConfigFile::RemovedApi.new("hosts.#{name}.#{key}", written.to_s.strip).message) if ConfigFile.removed_api?(written)
|
|
154
154
|
end
|
|
155
155
|
api = api.to_s.strip.downcase
|
|
156
|
-
if api.empty?
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
elsif ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(api)
|
|
161
|
-
# A raw-prompt api is the transport; a different transport contradicts it.
|
|
162
|
-
return invalid(name, "api '#{api}' conflicts with transport '#{transport}'") if transport && transport != api
|
|
163
|
-
|
|
164
|
-
transport = api
|
|
165
|
-
end
|
|
166
|
-
{ transport: transport&.to_sym, api: api&.to_sym }
|
|
156
|
+
return { api: nil } if api.empty?
|
|
157
|
+
return invalid(name, "unknown api '#{api}'") unless ConfigFile::VALID_APIS_FOR_CONFIG.include?(api)
|
|
158
|
+
|
|
159
|
+
{ api: api.to_sym }
|
|
167
160
|
end
|
|
168
|
-
private_class_method :
|
|
161
|
+
private_class_method :parse_api
|
|
169
162
|
|
|
170
163
|
def self.invalid(name, reason)
|
|
171
164
|
ConfigFile.warn_once "Warning: ignoring hosts entry '#{name}': #{reason}"
|
|
@@ -10,7 +10,7 @@ require_relative "llm/adapters"
|
|
|
10
10
|
require_relative "llm/dialect"
|
|
11
11
|
|
|
12
12
|
module Samagotchi
|
|
13
|
-
# HostRegistry manages multiple model hosts (llama.cpp
|
|
13
|
+
# HostRegistry manages multiple model hosts (llama.cpp, OpenAI-compatible, Anthropic) and
|
|
14
14
|
# provides lazy model discovery aggregation.
|
|
15
15
|
#
|
|
16
16
|
# - Hosts are defined in config.yml `hosts:` section or synthesized from
|
|
@@ -46,7 +46,7 @@ module Samagotchi
|
|
|
46
46
|
# llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
|
|
47
47
|
# llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
|
|
48
48
|
# models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
|
|
49
|
-
HostEntry = Struct.new(:name, :host, :port, :
|
|
49
|
+
HostEntry = Struct.new(:name, :host, :port, :client, :api, :scheme, :url, :api_key_env, :auth, :dialect,
|
|
50
50
|
:profile, :first_token_timeout, :vision, :sampling, :thinking, :reasoning_history, :remote,
|
|
51
51
|
:window_tokens, :steer_cut_after, :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens,
|
|
52
52
|
:models, keyword_init: true) do
|
|
@@ -126,7 +126,7 @@ module Samagotchi
|
|
|
126
126
|
# Every HostConfig field is a HostEntry member (a new one must be added there too).
|
|
127
127
|
fields = cfg.to_h.merge(name: key.to_s.downcase, port: cfg.port.to_i, api: cfg.api&.to_sym, models: cfg.models || {})
|
|
128
128
|
entry = HostEntry.new(**fields)
|
|
129
|
-
entry.client = Client.new(host: cfg.host, port: cfg.port,
|
|
129
|
+
entry.client = Client.new(host: cfg.host, port: cfg.port, scheme: cfg.scheme,
|
|
130
130
|
first_token_timeout: entry.first_token_limit, name: entry.name,
|
|
131
131
|
api_key_env: entry.api_key_env, auth: entry.auth_scheme, env: env)
|
|
132
132
|
@entries[entry.name] = entry
|
|
@@ -137,7 +137,7 @@ module Samagotchi
|
|
|
137
137
|
host = "localhost" if host.empty?
|
|
138
138
|
port = Config.get("server.port").to_i
|
|
139
139
|
port = 8080 if port <= 0
|
|
140
|
-
@entries["default"] = HostEntry.new(name: "default", host: host, port: port,
|
|
140
|
+
@entries["default"] = HostEntry.new(name: "default", host: host, port: port, models: {},
|
|
141
141
|
client: Client.new(host: host, port: port, name: "default"))
|
|
142
142
|
end
|
|
143
143
|
@mutex = Mutex.new
|
|
@@ -341,7 +341,7 @@ module Samagotchi
|
|
|
341
341
|
end
|
|
342
342
|
|
|
343
343
|
# List models on all hosts in parallel. On error per-host, skip with error entry (no failover).
|
|
344
|
-
# Returns { host_name => { host:, port:,
|
|
344
|
+
# Returns { host_name => { host:, port:, models: [LLM::ModelInfo], error: nil|String } }
|
|
345
345
|
# Also populates the model index. Unless forced, a host's list is reused
|
|
346
346
|
# for its TTL (60s; 10 minutes for a remote host).
|
|
347
347
|
#
|
|
@@ -361,12 +361,12 @@ module Samagotchi
|
|
|
361
361
|
Thread.new do
|
|
362
362
|
begin
|
|
363
363
|
models = list_models_for(entry)
|
|
364
|
-
data = { host: entry.host, port: entry.port,
|
|
364
|
+
data = { host: entry.host, port: entry.port, models: models, error: nil }
|
|
365
365
|
@mutex.synchronize { @host_lists[name] = { data: data, at: @clock.call } }
|
|
366
366
|
save_model_list(name, models)
|
|
367
367
|
rescue StandardError => e
|
|
368
368
|
Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
|
|
369
|
-
data = { host: entry.host, port: entry.port,
|
|
369
|
+
data = { host: entry.host, port: entry.port, models: [], error: list_error(e) }
|
|
370
370
|
end
|
|
371
371
|
results_mutex.synchronize { results[name] = data }
|
|
372
372
|
end
|
|
@@ -380,7 +380,7 @@ module Samagotchi
|
|
|
380
380
|
@entries.each_with_object({}) do |(name, entry), acc|
|
|
381
381
|
acc[name] = results.fetch(name) do
|
|
382
382
|
partial = true
|
|
383
|
-
{ host: entry.host, port: entry.port,
|
|
383
|
+
{ host: entry.host, port: entry.port, models: [],
|
|
384
384
|
error: "no answer in #{wait == wait.to_i ? wait.to_i : wait} s" }
|
|
385
385
|
end
|
|
386
386
|
end
|
|
@@ -193,7 +193,7 @@ module Samagotchi
|
|
|
193
193
|
attr_accessor :llm_context_turn
|
|
194
194
|
# @return [Integer, nil] the llama.cpp slot (`id_slot`) the last
|
|
195
195
|
# request of the last run streamed from; nil when the server named
|
|
196
|
-
# none
|
|
196
|
+
# none or no request was made
|
|
197
197
|
attr_reader :last_slot
|
|
198
198
|
|
|
199
199
|
# Run the conversation loop and return the final model response plus
|
|
@@ -294,8 +294,8 @@ module Samagotchi
|
|
|
294
294
|
Request = Struct.new(:prompt, :images, :image_tokens, :window, :prefill, keyword_init: true)
|
|
295
295
|
# One generation: the response (nil when cut), the cut's detail, this
|
|
296
296
|
# generation's own server counts, the model the server named, the
|
|
297
|
-
# thinking it streamed, and why it stopped (
|
|
298
|
-
# reason, Client
|
|
297
|
+
# thinking it streamed, and why it stopped (llama.cpp's finish
|
|
298
|
+
# reason, Client.finish_reason_from; nil when not named),
|
|
299
299
|
# and its prompt-cache counts (the largest seen: they come cumulative),
|
|
300
300
|
# and where its visible text starts in the turn's buffer.
|
|
301
301
|
Generation = Struct.new(:response, :cut, :usage, :served_model, :streamed_thinking, :finish_reason, :cache,
|
|
@@ -312,15 +312,32 @@ module Samagotchi
|
|
|
312
312
|
empty_retry: EmptyAnswerRetry.new, empty_steps: [], tool_activity: [], buffer: +"", streamed_thinking: +"",
|
|
313
313
|
qwen_attempts: 0, qwen_partial: nil,
|
|
314
314
|
# Qwen with thinking off: an empty thought after the cue, so the model
|
|
315
|
-
# answers at once
|
|
316
|
-
#
|
|
317
|
-
|
|
315
|
+
# answers at once; on, an open one where the server's template has
|
|
316
|
+
# it. Kept in the turn's model messages, so each tool-loop prompt
|
|
317
|
+
# starts with what the server already has cached.
|
|
318
|
+
prefill: native_prefill(@turn_settings.thinking || Thinking::DEFAULT),
|
|
318
319
|
pending_tool_calls: false, model_name: @turn_settings.model_name, pending_input: pending_input,
|
|
319
320
|
on_stream_event: on_stream_event, cancel_controller: cancel_controller, cap: cap,
|
|
320
321
|
emit: emit
|
|
321
322
|
)
|
|
322
323
|
end
|
|
323
324
|
|
|
325
|
+
# The text after the cue for +level+ (Thinking.native), set once per
|
|
326
|
+
# turn. Only qwen36 with thinking on asks the server's template whether
|
|
327
|
+
# it opens a thought: the /props answer the profile and the window
|
|
328
|
+
# probes cached (no template, as a router's own /props or a failed
|
|
329
|
+
# probe: no open thought, the prompt as before).
|
|
330
|
+
def native_prefill(level)
|
|
331
|
+
opens = @profile&.name == "qwen36" && level != :off && Thinking.opens_thought?(model_props)
|
|
332
|
+
Thinking.native(level, @profile, opens_thought: opens).prefill
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def model_props
|
|
336
|
+
@client.respond_to?(:server_props) ? @client.server_props(model: @turn_settings.model_name) : nil
|
|
337
|
+
rescue StandardError
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
|
|
324
341
|
# One iteration. Returns :next, :answer (the turn ends), or the turn's
|
|
325
342
|
# result (it ended cancelled).
|
|
326
343
|
def iterate(turn)
|
|
@@ -336,13 +353,28 @@ module Samagotchi
|
|
|
336
353
|
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
337
354
|
# returned fragment (non-nil only for Qwen) is fed back on the next
|
|
338
355
|
# iteration if the model opened a tool-call block it did not close.
|
|
339
|
-
calls, turn.qwen_partial = parser.parse_with_recovery(generation
|
|
356
|
+
calls, turn.qwen_partial = parser.parse_with_recovery(callable_text(request, generation), turn.qwen_partial)
|
|
340
357
|
calls = calls.map { |call| PromptLiteralGuard.restore_call(call, profile: @profile) }
|
|
341
358
|
return after_text(turn, request, generation) if calls.empty?
|
|
342
359
|
|
|
343
360
|
dispatch_calls(turn, calls)
|
|
344
361
|
end
|
|
345
362
|
|
|
363
|
+
# The reply's text the tool-call parse reads: with a closed thought
|
|
364
|
+
# (Qwen's </think>, the prefill's open thought included), what is left
|
|
365
|
+
# without its thought blocks, so a call the model only thought about
|
|
366
|
+
# neither runs nor asks for a recovery (the UIs show it as thinking).
|
|
367
|
+
# A thought never closed stays: a model that writes its call there
|
|
368
|
+
# still has it run. The model message keeps the whole reply.
|
|
369
|
+
def callable_text(request, generation)
|
|
370
|
+
response = generation.response.to_s
|
|
371
|
+
close = @profile.thought_close
|
|
372
|
+
full = request.prefill + response
|
|
373
|
+
return response unless close && full.include?(close)
|
|
374
|
+
|
|
375
|
+
strip_thought_blocks(full)
|
|
376
|
+
end
|
|
377
|
+
|
|
346
378
|
# The prompt for this iteration, after the context check: a rise into a
|
|
347
379
|
# bucket that asks the model for a change puts its line on the tail
|
|
348
380
|
# (the prompt cache keeps its prefix), and the prompt is formatted again
|
|
@@ -427,6 +459,10 @@ module Samagotchi
|
|
|
427
459
|
# thinking) leaves the splitter inside that block, so rebuilding it
|
|
428
460
|
# keeps the restarted stream's prose in the right lane.
|
|
429
461
|
stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
|
|
462
|
+
# The prompt's prefill is the reply's start: an open thought there
|
|
463
|
+
# streams as thinking. What the seed itself splits into is the
|
|
464
|
+
# prompt's, not shown.
|
|
465
|
+
stream_splitter.feed(request.prefill) unless request.prefill.empty?
|
|
430
466
|
# Each ask starts with streamed_any = false, but once any chunk of
|
|
431
467
|
# this ask is shown it stays shown: a held-back partial marker after
|
|
432
468
|
# real text must not clear it, or the drop below won't ask again.
|
|
@@ -635,7 +671,9 @@ module Samagotchi
|
|
|
635
671
|
return :next
|
|
636
672
|
end
|
|
637
673
|
|
|
638
|
-
|
|
674
|
+
# The prefill is the reply's start: an open thought it began closes in
|
|
675
|
+
# the response, and the whole block goes from the answer.
|
|
676
|
+
response = request.prefill + generation.response.to_s
|
|
639
677
|
answer = -> { PromptLiteralGuard.restore(strip_thought_blocks(response), profile: @profile) }
|
|
640
678
|
empty = strip_thought_blocks(response.to_s).strip.empty?
|
|
641
679
|
retry_empty = empty && turn.empty_retry.retry_empty?(iteration: turn.iteration,
|
|
@@ -25,9 +25,14 @@ module Samagotchi
|
|
|
25
25
|
# shipped meta expands: includes: in any other bundle is ignored.
|
|
26
26
|
module Profile
|
|
27
27
|
# installed: members installed now; already: members found installed
|
|
28
|
-
# (recorded, not reinstalled); skipped and failed: name => why
|
|
29
|
-
|
|
28
|
+
# (recorded, not reinstalled); skipped and failed: name => why;
|
|
29
|
+
# kept_own: member installed now => its memory files the user already
|
|
30
|
+
# had, left as they are and not owned (Installer#skip_existing).
|
|
31
|
+
InstallResult = Struct.new(:name, :version, :installed, :already, :skipped, :failed, :kept_own, keyword_init: true) do
|
|
30
32
|
def failed? = !failed.empty?
|
|
33
|
+
|
|
34
|
+
# One line per kept file (Profile.kept_own_line).
|
|
35
|
+
def kept_own_lines = (kept_own || {}).flat_map { |member, files| files.map { |file| Profile.kept_own_line(member, file) } }
|
|
31
36
|
end
|
|
32
37
|
# removed: members uninstalled; blocked: name => why; gone: the meta
|
|
33
38
|
# itself was removed (only once every member is); trash: member =>
|
|
@@ -69,6 +74,15 @@ module Samagotchi
|
|
|
69
74
|
|
|
70
75
|
def installed_names = Provenance.each_installed.map { |name, _| name }
|
|
71
76
|
|
|
77
|
+
# What a profile's install says of a member's memory file the user
|
|
78
|
+
# already had: chi bundle install says "Skipped … (already exists)",
|
|
79
|
+
# which a profile's member install would leave unsaid (docs/memory.md,
|
|
80
|
+
# "Shipped notes").
|
|
81
|
+
def kept_own_line(member, file)
|
|
82
|
+
"#{member}: kept your own #{file} (not the bundle's; to take the bundle's, remove yours with " \
|
|
83
|
+
"memory_write remove: true, then chi bundle install #{member})"
|
|
84
|
+
end
|
|
85
|
+
|
|
72
86
|
# The one rule.
|
|
73
87
|
def to_install(manifest, bundle = Provenance.new(name: manifest.name).record, installed: installed_names)
|
|
74
88
|
manifest.includes - recorded(bundle, manifest) - installed
|
|
@@ -84,7 +98,7 @@ module Samagotchi
|
|
|
84
98
|
was = recorded(bundle, manifest)
|
|
85
99
|
installed = installed_names
|
|
86
100
|
result = InstallResult.new(name: manifest.name, version: manifest.version, installed: [],
|
|
87
|
-
already: (manifest.includes - was) & installed, skipped: {}, failed: {})
|
|
101
|
+
already: (manifest.includes - was) & installed, skipped: {}, failed: {}, kept_own: {})
|
|
88
102
|
|
|
89
103
|
to_install(manifest, bundle, installed: installed).each do |member|
|
|
90
104
|
install_member(member, shipped_dir, chi_version, dry_run, force, result)
|
|
@@ -109,8 +123,12 @@ module Samagotchi
|
|
|
109
123
|
return result.skipped[member] = failure if failure
|
|
110
124
|
return result.installed << member if dry_run
|
|
111
125
|
|
|
112
|
-
Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true,
|
|
126
|
+
installer = Installer.new(source: member_dir, name: member, scope: "system", force: force, strict: true,
|
|
127
|
+
shipped_dir: shipped_dir)
|
|
128
|
+
installer.run
|
|
113
129
|
result.installed << member
|
|
130
|
+
kept = installer.results.select { |_, entry| entry[:status] == "skipped" && entry[:reason] == "already exists" }.keys
|
|
131
|
+
result.kept_own[member] = kept.sort unless kept.empty?
|
|
114
132
|
rescue StandardError => e
|
|
115
133
|
result.failed[member] = e.message.lines.first.to_s.strip
|
|
116
134
|
end
|
|
@@ -37,7 +37,10 @@ module Samagotchi
|
|
|
37
37
|
# :failed. note says why for skipped/failed. kept and replaced are
|
|
38
38
|
# file names; under plan they are what apply would keep or replace.
|
|
39
39
|
# adds is nil but for a profile: the members it installs.
|
|
40
|
-
|
|
40
|
+
# kept_own is nil but for a profile that installed a member: its
|
|
41
|
+
# Profile.kept_own_line lines.
|
|
42
|
+
Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, :adds, :kept_own,
|
|
43
|
+
keyword_init: true)
|
|
41
44
|
|
|
42
45
|
module_function
|
|
43
46
|
|
|
@@ -84,6 +87,7 @@ module Samagotchi
|
|
|
84
87
|
result = Profile.install(row.source_dir, shipped_dir: File.dirname(row.source_dir))
|
|
85
88
|
row.dup.tap do |r|
|
|
86
89
|
r.adds = result.installed
|
|
90
|
+
r.kept_own = result.kept_own_lines
|
|
87
91
|
r.status = result.failed? ? :failed : :updated
|
|
88
92
|
r.note = result.failed.map { |member, why| "#{member}: #{why}" }.join("; ") if result.failed?
|
|
89
93
|
end
|
|
@@ -484,12 +484,11 @@ module Samagotchi
|
|
|
484
484
|
private_class_method :configured
|
|
485
485
|
|
|
486
486
|
# Only a native llama.cpp host has /props with a chat template; a chat
|
|
487
|
-
# host (api: openai or anthropic) barely uses a profile
|
|
487
|
+
# host (api: openai or anthropic) barely uses a profile.
|
|
488
488
|
def self.probe?(entry, client)
|
|
489
489
|
return false if client.nil? || !client.respond_to?(:server_props)
|
|
490
|
-
return false if entry&.chat?
|
|
491
490
|
|
|
492
|
-
|
|
491
|
+
!entry&.chat?
|
|
493
492
|
end
|
|
494
493
|
private_class_method :probe?
|
|
495
494
|
|
|
@@ -345,8 +345,6 @@ module Samagotchi
|
|
|
345
345
|
when :config then "#{name} (config #{result.detail})"
|
|
346
346
|
when :cli, :env then "#{name} (#{result.source})"
|
|
347
347
|
else
|
|
348
|
-
return "#{name} (#{result.source})" unless entry.nil? || entry.transport.nil? || entry.transport == :llama_cpp
|
|
349
|
-
|
|
350
348
|
given = result.source == :name ? "name says #{name}" : "default #{name}"
|
|
351
349
|
"from the server at runtime (#{given})"
|
|
352
350
|
end
|
|
@@ -96,7 +96,7 @@ module Samagotchi
|
|
|
96
96
|
@section_notes = nil
|
|
97
97
|
@section_files = []
|
|
98
98
|
@built[key] = system_prompt_with_index(
|
|
99
|
-
assist_system_prompt(chat: chat,
|
|
99
|
+
assist_system_prompt(chat: chat, layers: layers), chat: chat, thinking: thinking, layers: layers
|
|
100
100
|
)
|
|
101
101
|
@index_sizes[key] = @measured_index
|
|
102
102
|
@notes_loaded[key] = @section_notes || []
|
|
@@ -151,9 +151,8 @@ module Samagotchi
|
|
|
151
151
|
|
|
152
152
|
# The base prompt (specs, plugins' declarations), with Gemma's tool
|
|
153
153
|
# declarations at its end as #build has them.
|
|
154
|
-
def base(chat: false,
|
|
155
|
-
with_tail_declarations(assist_system_prompt(chat: chat,
|
|
156
|
-
layers: layers)
|
|
154
|
+
def base(chat: false, layers: [])
|
|
155
|
+
with_tail_declarations(assist_system_prompt(chat: chat, layers: layers), chat: chat, layers: layers)
|
|
157
156
|
end
|
|
158
157
|
|
|
159
158
|
# Names activated via preloaded --memory entries during prompt
|
|
@@ -195,27 +194,14 @@ module Samagotchi
|
|
|
195
194
|
end
|
|
196
195
|
end
|
|
197
196
|
|
|
198
|
-
# Only Qwen has an explicit thinking-close marker, so only Qwen can
|
|
199
|
-
# reliably have this preamble parsed back out of its thinking block.
|
|
200
|
-
# With thinking off there is no thinking to begin with it.
|
|
201
|
-
def turn_preamble_instruction(thinking = nil)
|
|
202
|
-
return "" unless profile.name == "qwen36"
|
|
203
|
-
return "" if Samagotchi::Config.get("thinking.turn_preamble") == false
|
|
204
|
-
return "" if (thinking || turn_thinking) == :off
|
|
205
|
-
|
|
206
|
-
"\nTurn preamble: as the very first line of your thinking, write \"TURN: \" followed by a short present-tense action phrase (max 8 words) describing what you are about to do, e.g. \"TURN: reading project config\". Then continue reasoning normally.\n"
|
|
207
|
-
end
|
|
208
|
-
|
|
209
197
|
# ── System prompts ─────────────────────────────────────────────────────────
|
|
210
198
|
|
|
211
|
-
# @param chat [Boolean] for the chat loop: no tool declarations
|
|
212
|
-
# syntax
|
|
213
|
-
|
|
214
|
-
def assist_system_prompt(chat: false, thinking: nil, layers: [])
|
|
199
|
+
# @param chat [Boolean] for the chat loop: no tool declarations or call
|
|
200
|
+
# syntax (its tools go as schemas with each request)
|
|
201
|
+
def assist_system_prompt(chat: false, layers: [])
|
|
215
202
|
return chat_system_prompt if chat
|
|
216
203
|
|
|
217
204
|
hint = tool_call_hint
|
|
218
|
-
turn_preamble = turn_preamble_instruction(thinking)
|
|
219
205
|
# Gemma 4's template declares the tools at the end of the system turn
|
|
220
206
|
# (#system_prompt_with_index puts them there).
|
|
221
207
|
tools = if gemma_tail_declarations?
|
|
@@ -228,7 +214,7 @@ module Samagotchi
|
|
|
228
214
|
You are Chi (pronounced "chee"), the friendly name for the Samagotchi assistant harness. #{tools}
|
|
229
215
|
#{hint}
|
|
230
216
|
You may make multiple tool calls. After seeing tool results, continue reasoning or answer the user.
|
|
231
|
-
|
|
217
|
+
|
|
232
218
|
#{ToolDeclarations::SMALL_CONTEXT_PROTOCOL}
|
|
233
219
|
|
|
234
220
|
#{assist_guidance}
|
|
@@ -20,8 +20,6 @@ module Samagotchi
|
|
|
20
20
|
|
|
21
21
|
# Enough text to find the newest sentence in; scanning grows with it.
|
|
22
22
|
BUFFER_LIMIT = 2000
|
|
23
|
-
# The Qwen turn preamble's first line ("TURN: reading the config").
|
|
24
|
-
TURN_PREFIX = /\ATURN:\s*/i
|
|
25
23
|
# Where a raw stream's thinking ends: a boundary, so its last words
|
|
26
24
|
# don't run into the answer's first.
|
|
27
25
|
THOUGHT_CLOSE = %r{<channel\|>|</think>}
|
|
@@ -65,8 +63,8 @@ module Samagotchi
|
|
|
65
63
|
# @return [Symbol, nil] :thinking or :writing
|
|
66
64
|
attr_reader :label
|
|
67
65
|
|
|
68
|
-
# The sentence as a row shows it:
|
|
69
|
-
def sentence = strip_markdown(@ticker.line
|
|
66
|
+
# The sentence as a row shows it: markdown plain.
|
|
67
|
+
def sentence = strip_markdown(@ticker.line)
|
|
70
68
|
|
|
71
69
|
# #sentence cut to +room+ columns, with an ellipsis when cut.
|
|
72
70
|
def fit(room)
|
|
@@ -124,6 +124,32 @@ module Samagotchi
|
|
|
124
124
|
File.join(memory_dir(scope, cwd: cwd || Dir.pwd), "#{name}.md")
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
+
# The model notes chi loads into the prompt for +model_id+ (the bare
|
|
128
|
+
# id, no host prefix: `deepseek/deepseek-v4.1-flash`), from this
|
|
129
|
+
# sandbox's memories (the system scope's, then the project scope's for
|
|
130
|
+
# the checkout at +cwd+), matched as chi matches them: each `models:`
|
|
131
|
+
# glob against the id and its model key.
|
|
132
|
+
# @param small [Boolean, nil] whether the model counts as a small one
|
|
133
|
+
# for a `small` entry; nil: chi's own rule (guardrails.small_models)
|
|
134
|
+
# @return [Array<#name, #scope, #chars, #body>] the notes in prompt
|
|
135
|
+
# order (`.map(&:name)` for the names); none: []
|
|
136
|
+
def model_notes_for(model_id, small: nil, cwd: nil)
|
|
137
|
+
require_relative "../model_notes"
|
|
138
|
+
require_relative "../model_overlay"
|
|
139
|
+
small_check = small.nil? ? nil : -> { small }
|
|
140
|
+
in_dir(cwd) do
|
|
141
|
+
ModelNotes.for(name: model_id, key: ModelOverlay.key_for(model_id), small: small_check, warn: false)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The sizes chi warns over (it still loads the notes): one note's
|
|
146
|
+
# body (+:note+) and all of a model's notes (+:total+), in chars.
|
|
147
|
+
# @return [Hash{Symbol => Integer}]
|
|
148
|
+
def model_note_limits
|
|
149
|
+
require_relative "../model_notes"
|
|
150
|
+
{ note: ModelNotes::NOTE_LIMIT, total: ModelNotes::TOTAL_LIMIT }
|
|
151
|
+
end
|
|
152
|
+
|
|
127
153
|
# @return [String] a temp folder for the session's cwd
|
|
128
154
|
def work_dir = File.join(@root, "work")
|
|
129
155
|
|
data/lib/samagotchi/thinking.rb
CHANGED
|
@@ -27,6 +27,14 @@ module Samagotchi
|
|
|
27
27
|
# An empty thought after the Qwen assistant cue: the model answers at once
|
|
28
28
|
# (what the server's own template does with enable_thinking false).
|
|
29
29
|
QWEN_EMPTY_THOUGHT = "<think>\n\n</think>\n\n"
|
|
30
|
+
# An open thought after the Qwen assistant cue, as a Qwen3.5+ template
|
|
31
|
+
# writes it with thinking on: without it Qwen3.6 writes an empty thought
|
|
32
|
+
# at once and never thinks (#opens_thought?).
|
|
33
|
+
QWEN_OPEN_THOUGHT = "<think>\n"
|
|
34
|
+
# A Jinja string literal that ends with the open thought: '<think>\n'
|
|
35
|
+
# (a Qwen3.5+ generation prompt). Qwen3's writes only the empty one,
|
|
36
|
+
# '<think>\n\n</think>\n\n', with thinking off.
|
|
37
|
+
OPEN_THOUGHT_LITERAL = /<think>(?:\\n|\n)['"]/
|
|
30
38
|
# Gemma 4's empty thought channel after the model cue, as its chat
|
|
31
39
|
# template writes it with enable_thinking false (only where a model turn
|
|
32
40
|
# starts: Prompt.prefill_for).
|
|
@@ -168,15 +176,40 @@ module Samagotchi
|
|
|
168
176
|
caps.is_a?(Hash) && caps["supports_reasoning_effort"] == false
|
|
169
177
|
end
|
|
170
178
|
|
|
179
|
+
# Whether a llama.cpp /props answer (+props+, Client::ServerProps)
|
|
180
|
+
# carries a chat template whose generation prompt opens a thought
|
|
181
|
+
# (Qwen3.5 and later: `assistant\n` then '<think>\n' unless
|
|
182
|
+
# enable_thinking is false). Read from the template's last
|
|
183
|
+
# add_generation_prompt branch on: the message loop before it writes
|
|
184
|
+
# earlier turns' thoughts. false when there is no template (a router's
|
|
185
|
+
# own /props, an unloaded model, a failed probe).
|
|
186
|
+
def opens_thought?(props)
|
|
187
|
+
return false unless props.respond_to?(:answered?) && props.answered? && props.body.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
template = props.body["chat_template"]
|
|
190
|
+
return false unless template.is_a?(String)
|
|
191
|
+
|
|
192
|
+
cue = template.rindex("add_generation_prompt")
|
|
193
|
+
!cue.nil? && OPEN_THOUGHT_LITERAL.match?(template[cue..])
|
|
194
|
+
end
|
|
195
|
+
|
|
171
196
|
# The native prompt's switch for +level+ under +profile+.
|
|
172
197
|
# @param profile [ModelProfile]
|
|
198
|
+
# @param opens_thought [Boolean] the server's template opens a thought
|
|
199
|
+
# after the cue (#opens_thought?): qwen36 then gets the open thought
|
|
200
|
+
# at every level but off, as the template has it
|
|
173
201
|
# @return [Native]
|
|
174
|
-
def native(level, profile)
|
|
202
|
+
def native(level, profile, opens_thought: false)
|
|
175
203
|
off = level == :off
|
|
176
204
|
honoured = !EFFORTS.include?(level)
|
|
177
205
|
case profile&.name
|
|
178
206
|
when "gemma4" then Native.new(system_token: off ? "" : GEMMA_THINK_TOKEN, prefill: off ? GEMMA_EMPTY_THOUGHT : "", honoured: honoured)
|
|
179
|
-
when "qwen36"
|
|
207
|
+
when "qwen36"
|
|
208
|
+
prefill = if off then QWEN_EMPTY_THOUGHT
|
|
209
|
+
elsif opens_thought then QWEN_OPEN_THOUGHT
|
|
210
|
+
else ""
|
|
211
|
+
end
|
|
212
|
+
Native.new(system_token: "", prefill: prefill, honoured: honoured)
|
|
180
213
|
else Native.new(system_token: "", prefill: "", honoured: level == DEFAULT)
|
|
181
214
|
end
|
|
182
215
|
end
|
|
@@ -102,16 +102,23 @@ module Samagotchi
|
|
|
102
102
|
private
|
|
103
103
|
|
|
104
104
|
# An effort on a native host, which has no knob for it: said once per
|
|
105
|
-
# session and host.
|
|
105
|
+
# session and host. A qwen36 whose server template opens a thought
|
|
106
|
+
# (Thinking.opens_thought?, from the /props answer the profile probe
|
|
107
|
+
# cached) thinks at every effort, the same.
|
|
106
108
|
def announce_level(level, target)
|
|
107
109
|
return if target.entry.chat?
|
|
108
110
|
|
|
109
111
|
profile = @profile.call
|
|
110
112
|
return if Thinking.native(level, profile).honoured
|
|
111
113
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
text = if profile&.name == "qwen36" && Thinking.opens_thought?(target.client&.cached_server_props(model: target.bare_model))
|
|
115
|
+
"#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
|
|
116
|
+
"it thinks, with no effort control (low, medium and high think the same)"
|
|
117
|
+
else
|
|
118
|
+
"#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
|
|
119
|
+
"thinking stays as the model has it"
|
|
120
|
+
end
|
|
121
|
+
notice_once(:unsupported, target, :info, text)
|
|
115
122
|
rescue StandardError => e
|
|
116
123
|
failed(:announce_level, e)
|
|
117
124
|
end
|
|
@@ -203,7 +203,7 @@ module Samagotchi
|
|
|
203
203
|
row = Row.new(component: update.name, from: update.from, to: update.to)
|
|
204
204
|
case update.status
|
|
205
205
|
when :would_update, :updated
|
|
206
|
-
notes = [adds(update.adds), kept(update.kept, update.name), replaced(update.replaced)].compact
|
|
206
|
+
notes = [adds(update.adds), kept(update.kept, update.name), replaced(update.replaced), *update.kept_own].compact
|
|
207
207
|
done(row, :update, notes.empty? ? nil : notes.join("; "))
|
|
208
208
|
when :up_to_date then up_to_date(row)
|
|
209
209
|
when :skipped then skipped(row, update.note)
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -61,9 +61,6 @@ module Samagotchi
|
|
|
61
61
|
|
|
62
62
|
def self.native(target, _entry, profile, trust_config:)
|
|
63
63
|
client = target.client
|
|
64
|
-
transport = client.transport.name
|
|
65
|
-
return no("#{transport} hosts take no images (only llama.cpp does)") unless transport == :llama_cpp
|
|
66
|
-
|
|
67
64
|
props = client.server_props(model: target.bare_model)
|
|
68
65
|
return no("can't reach /props for the media marker") unless props&.answered?
|
|
69
66
|
|