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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -1
  3. data/README.md +8 -8
  4. data/bin/chi +6 -2
  5. data/docs/architecture.md +4 -5
  6. data/docs/cli.md +4 -4
  7. data/docs/configuration.md +49 -86
  8. data/docs/hooks.md +2 -2
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/context-telemetry.md +1 -1
  11. data/docs/internals/engine.md +7 -4
  12. data/docs/memory.md +26 -1
  13. data/docs/plugins.md +37 -0
  14. data/docs/releasing.md +2 -2
  15. data/docs/sessions.md +1 -1
  16. data/docs/testing.md +16 -11
  17. data/lib/samagotchi/bootstrap/config_writer.rb +1 -5
  18. data/lib/samagotchi/bootstrap_command.rb +1 -0
  19. data/lib/samagotchi/bundle_command/try/sandbox.rb +8 -2
  20. data/lib/samagotchi/bundle_command.rb +1 -0
  21. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  22. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  23. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  24. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  25. data/lib/samagotchi/bundles/system/config_modification_protocol.md +6 -7
  26. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  27. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  28. data/lib/samagotchi/client.rb +59 -196
  29. data/lib/samagotchi/config.rb +74 -15
  30. data/lib/samagotchi/engine.rb +7 -7
  31. data/lib/samagotchi/host_config.rb +20 -27
  32. data/lib/samagotchi/host_registry.rb +8 -8
  33. data/lib/samagotchi/kernel_loop.rb +46 -8
  34. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  35. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  36. data/lib/samagotchi/model_profile.rb +2 -3
  37. data/lib/samagotchi/self_report.rb +0 -2
  38. data/lib/samagotchi/system_prompt.rb +7 -21
  39. data/lib/samagotchi/terminal_ui/thinking_line.rb +2 -4
  40. data/lib/samagotchi/testing/sandbox.rb +26 -0
  41. data/lib/samagotchi/thinking.rb +35 -2
  42. data/lib/samagotchi/thinking_notices.rb +11 -4
  43. data/lib/samagotchi/update_command.rb +1 -1
  44. data/lib/samagotchi/version.rb +1 -1
  45. data/lib/samagotchi/vision_support.rb +0 -3
  46. data/lib/samagotchi/web/public/app.js +15 -82
  47. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  48. data/lib/samagotchi/web/public/index.html +3 -3
  49. data/lib/samagotchi/web/public/toast_place.js +1 -1
  50. 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; transport,
17
- # api: symbols, nil when unset; auth: the header the API key goes in
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, :transport, :api, :api_key_env, :auth, :dialect, :profile,
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
- kinds = parse_kinds(name, value.call(:transport), value.call(:api)) or return nil
83
- location = parse_location(name, value, api: kinds[:api]) or return nil
84
- fields[:dialect] = LLM::Dialect.parse(value.call(:dialect), where, api: kinds[:api])
85
- new(name: name.downcase, **location, **kinds, **fields)
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
- # transport: and api: as symbols; a raw-prompt api is also the
147
- # transport. nil (warned) when unknown or contradicting each other.
148
- def self.parse_kinds(name, transport, api)
149
- transport = transport.to_s.strip.downcase
150
- if transport.empty?
151
- transport = nil
152
- elsif !ConfigFile::VALID_TRANSPORTS_FOR_CONFIG.include?(transport)
153
- return invalid(name, "unknown transport '#{transport}'")
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
- api = nil
158
- elsif !ConfigFile::VALID_APIS_FOR_CONFIG.include?(api)
159
- return invalid(name, "unknown api '#{api}'")
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 :parse_kinds
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 / mlx / oMLX) and
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, :transport, :client, :api, :scheme, :url, :api_key_env, :auth, :dialect,
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, transport: cfg.transport, scheme: cfg.scheme,
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, transport: nil, models: {},
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:, transport:, models: [LLM::ModelInfo], error: nil|String } }
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, transport: entry.transport, models: models, error: nil }
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, transport: entry.transport, models: [], error: list_error(e) }
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, transport: entry.transport, models: [],
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 (other transports) or no request was made
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 (the transport's finish
298
- # reason, Client::Transport#finish_reason_from; nil when not named),
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. Kept in the turn's model messages, so each tool-loop
316
- # prompt starts with what the server already has cached.
317
- prefill: Thinking.native(@turn_settings.thinking || Thinking::DEFAULT, @profile).prefill,
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.response, turn.qwen_partial)
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
- response = generation.response
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
- InstallResult = Struct.new(:name, :version, :installed, :already, :skipped, :failed, keyword_init: true) do
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, shipped_dir: shipped_dir).run
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
- Row = Struct.new(:name, :from, :to, :status, :note, :kept, :replaced, :source_dir, :scope, :adds, keyword_init: true)
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, and mlx/oMLX expose none.
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
- client.transport.name == :llama_cpp
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, thinking: thinking, layers: layers), chat: chat, thinking: thinking, layers: layers
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, thinking: nil, layers: [])
155
- with_tail_declarations(assist_system_prompt(chat: chat, thinking: thinking, layers: layers), 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, call
212
- # syntax or turn preamble (its tools go as schemas with each request)
213
- # @param thinking [Symbol, nil] the level (Thinking); nil: the effective model's
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
- #{turn_preamble}
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: no TURN: prefix, markdown plain.
69
- def sentence = strip_markdown(@ticker.line.sub(TURN_PREFIX, ""))
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
 
@@ -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" then Native.new(system_token: "", prefill: off ? QWEN_EMPTY_THOUGHT : "", honoured: honoured)
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
- notice_once(:unsupported, target, :info,
113
- "#{level} isn't supported by native #{profile.name} on #{target.entry.name}; " \
114
- "thinking stays as the model has it")
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)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.93.0"
4
+ VERSION = "0.95.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -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